diff --git a/MLS/.swiftlint.yml b/MLS/.swiftlint.yml index 6fa49957..16cb8d52 100644 --- a/MLS/.swiftlint.yml +++ b/MLS/.swiftlint.yml @@ -7,8 +7,8 @@ line_length: error: 300 function_body_length: - warning: 80 - error: 120 + warning: 100 + error: 150 disabled_rules: - force_cast diff --git a/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift b/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift index 2b343a23..c531c810 100644 --- a/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift +++ b/MLS/Data/Data/Network/DTO/AlarmDTO/AlarmResponseDTO.swift @@ -35,7 +35,7 @@ public struct AlarmResponseDTO: Decodable { public let type: String public let title: String public let link: String - public let date: [Int] + public let date: String } public struct AllContent: Decodable { diff --git a/MLS/Data/Data/Network/DTO/BookmarkDTO/SetBookmarkDTO.swift b/MLS/Data/Data/Network/DTO/BookmarkDTO/SetBookmarkDTO.swift new file mode 100644 index 00000000..bb287fbb --- /dev/null +++ b/MLS/Data/Data/Network/DTO/BookmarkDTO/SetBookmarkDTO.swift @@ -0,0 +1,11 @@ +import DomainInterface + +public struct SetBookmarkDTO: Decodable { + public let bookmarkId: Int + public let bookmarkType: String + public let resourceId: Int + + public func toDomain() -> Int { + return self.bookmarkId + } +} diff --git a/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Item/DictionaryDetailItemResponseDTO.swift b/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Item/DictionaryDetailItemResponseDTO.swift index 656b43fa..2cf32564 100644 --- a/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Item/DictionaryDetailItemResponseDTO.swift +++ b/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Item/DictionaryDetailItemResponseDTO.swift @@ -1,7 +1,7 @@ import DomainInterface public struct DictionaryDetailItemResponseDTO: Decodable { - public let itemId: Int? + public let itemId: Int public let nameKr: String? public let nameEn: String? public let descriptionText: String? diff --git a/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Map/DictionaryDetailMapResponseDTO.swift b/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Map/DictionaryDetailMapResponseDTO.swift index 55d8b109..653158e9 100644 --- a/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Map/DictionaryDetailMapResponseDTO.swift +++ b/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Map/DictionaryDetailMapResponseDTO.swift @@ -1,7 +1,7 @@ import DomainInterface public struct DictionaryDetailMapResponseDTO: Decodable { - public let mapId: Int? + public let mapId: Int public let nameKr: String? public let nameEn: String? public let regionName: String? diff --git a/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Quest/DictionaryDetailQuestResponseDTO.swift b/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Quest/DictionaryDetailQuestResponseDTO.swift index 2376ce48..e79a4294 100644 --- a/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Quest/DictionaryDetailQuestResponseDTO.swift +++ b/MLS/Data/Data/Network/DTO/DictionaryDetailDTO/Quest/DictionaryDetailQuestResponseDTO.swift @@ -1,7 +1,7 @@ import DomainInterface public struct DictionaryDetailQuestResponseDTO: Decodable { - public let questId: Int? + public let questId: Int public let titlePrefix: String? public let nameKr: String? public let nameEn: String? diff --git a/MLS/Data/Data/Network/DTO/EmptyResponseDTO.swift b/MLS/Data/Data/Network/DTO/EmptyResponseDTO.swift new file mode 100644 index 00000000..2f92153f --- /dev/null +++ b/MLS/Data/Data/Network/DTO/EmptyResponseDTO.swift @@ -0,0 +1,5 @@ +public struct EmptyResponseDTO: Decodable { + func toBookmarkDomain() -> Int? { + return nil + } +} diff --git a/MLS/Data/Data/Network/Endpoints/AuthEndPoint.swift b/MLS/Data/Data/Network/Endpoints/AuthEndPoint.swift index 2e45ebbd..854eb68e 100644 --- a/MLS/Data/Data/Network/Endpoints/AuthEndPoint.swift +++ b/MLS/Data/Data/Network/Endpoints/AuthEndPoint.swift @@ -61,12 +61,11 @@ public enum AuthEndPoint { ) } - public static func fcmToken(credential: String, body: Encodable) -> ResponsableEndPoint { + public static func fcmToken(body: Encodable) -> ResponsableEndPoint { .init( baseURL: base, path: "/api/v1/auth/member/fcm-token", method: .PUT, - headers: ["Authorization": "Bearer \(credential)"], body: body ) } diff --git a/MLS/Data/Data/Network/Endpoints/BookmarkEndPoint.swift b/MLS/Data/Data/Network/Endpoints/BookmarkEndPoint.swift index e81ffa9f..8185672f 100644 --- a/MLS/Data/Data/Network/Endpoints/BookmarkEndPoint.swift +++ b/MLS/Data/Data/Network/Endpoints/BookmarkEndPoint.swift @@ -3,7 +3,7 @@ import DomainInterface public enum BookmarkEndPoint { static let base = "https://api.mapleland.kro.kr" - public static func setBookmark(body: Encodable) -> ResponsableEndPoint<[BookmarkDTO]> { + public static func setBookmark(body: Encodable) -> ResponsableEndPoint { .init( baseURL: base, path: "/api/v1/bookmarks", @@ -12,7 +12,7 @@ public enum BookmarkEndPoint { ) } - public static func deleteBookmark(bookmarkId: Int) -> ResponsableEndPoint<[BookmarkDTO]> { + public static func deleteBookmark(bookmarkId: Int) -> ResponsableEndPoint { .init( baseURL: base, path: "/api/v1/bookmarks/\(bookmarkId)", diff --git a/MLS/Data/Data/Network/Endpoints/CollectionEndPoint.swift b/MLS/Data/Data/Network/Endpoints/CollectionEndPoint.swift index 5d2a22b2..2d87de65 100644 --- a/MLS/Data/Data/Network/Endpoints/CollectionEndPoint.swift +++ b/MLS/Data/Data/Network/Endpoints/CollectionEndPoint.swift @@ -24,24 +24,6 @@ public enum CollectionEndPoint { ) } - public static func addBookmarksToCollection(id: Int, body: Encodable) -> EndPoint { - .init( - baseURL: base, - path: "/api/v1/collections/\(id)/bookmarks", - method: .POST, - body: body - ) - } - - public static func addCollectionsToBookmark(id: Int, body: Encodable) -> EndPoint { - .init( - baseURL: base, - path: "/api/v1/bookmarks/\(id)/collections", - method: .POST, - body: body - ) - } - public static func setCollectionName(id: Int, body: Encodable) -> EndPoint { .init( baseURL: base, diff --git a/MLS/Data/Data/Providers/Network/NetworkProviderImpl.swift b/MLS/Data/Data/Providers/Network/NetworkProviderImpl.swift index f52e6e5d..eced66d6 100644 --- a/MLS/Data/Data/Providers/Network/NetworkProviderImpl.swift +++ b/MLS/Data/Data/Providers/Network/NetworkProviderImpl.swift @@ -28,11 +28,17 @@ public final class NetworkProviderImpl: NetworkProvider { print("✅ requestData: 응답 수신") if let data = data { - print("📦 requestData: 응답 데이터 있음 - \(String(data: data, encoding: .utf8) ?? "디코딩 실패")") +// print("📦 requestData: 응답 데이터 있음 - \(String(data: data, encoding: .utf8) ?? "디코딩 실패")") + print("📦 requestData: 응답 데이터 있음") do { let decoded = try JSONDecoder().decode(APIDefaultResponseDTO.self, from: data) - print("🎯 requestData: 디코딩 성공 - \(decoded)") - observer.onNext(decoded.data!) +// print("🎯 requestData: 디코딩 성공 - \(decoded)") + print("🎯 requestData: 디코딩 성공") + if let decodedData = decoded.data { + observer.onNext(decodedData) + } else { + observer.onNext(EmptyResponseDTO() as! T.Response) + } observer.onCompleted() } catch { print("❌ requestData: 디코딩 실패 - \(error)") @@ -115,6 +121,7 @@ private extension NetworkProviderImpl { completion(.success(data)) case .failure(let error): completion(.failure(error)) + print("API 통신에러 \(error)") } } task.resume() diff --git a/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift b/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift index 56449af0..6e2763d7 100644 --- a/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/AlarmAPIRepositoryImpl.swift @@ -13,32 +13,32 @@ public class AlarmAPIRepositoryImpl: AlarmAPIRepository { self.tokenInterceptor = interceptor } - public func fetchPatchNotes(cursor: [Int]?, pageSize: Int) -> Observable> { - let endpoint = AlarmEndPoint.fetchPatchNotes(query: AlarmQuery(cursor: cursor, pageSize: 999/*pageSize*/)) + public func fetchPatchNotes(cursor: String?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchPatchNotes(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchNotices(cursor: [Int]?, pageSize: Int) -> Observable> { - let endpoint = AlarmEndPoint.fetchNotices(query: AlarmQuery(cursor: cursor, pageSize: 999/*pageSize*/)) + public func fetchNotices(cursor: String?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchNotices(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchOutdatedEvents(cursor: [Int]?, pageSize: Int) -> Observable> { - let endpoint = AlarmEndPoint.fetchOutdatedEvents(query: AlarmQuery(cursor: cursor, pageSize: 999/*pageSize*/)) + public func fetchOutdatedEvents(cursor: String?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchOutdatedEvents(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchOngoingEvents(cursor: [Int]?, pageSize: Int) -> Observable> { - let endpoint = AlarmEndPoint.fetchOngoingEvents(query: AlarmQuery(cursor: cursor, pageSize: 999/*pageSize*/)) + public func fetchOngoingEvents(cursor: String?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchOngoingEvents(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAlarmDomain() } } - public func fetchAll(cursor: [Int]?, pageSize: Int) -> Observable> { - let endpoint = AlarmEndPoint.fetchAll(query: AlarmQuery(cursor: cursor, pageSize: 999/*pageSize*/)) + public func fetchAll(cursor: String?, pageSize: Int) -> Observable> { + let endpoint = AlarmEndPoint.fetchAll(query: AlarmQuery(cursor: cursor, pageSize: pageSize)) return provider.requestData(endPoint: endpoint, interceptor: tokenInterceptor) .map { $0.toAllAlarmDomain() } } @@ -52,7 +52,7 @@ public class AlarmAPIRepositoryImpl: AlarmAPIRepository { private extension AlarmAPIRepositoryImpl { struct AlarmQuery: Encodable { - let cursor: [Int]? + let cursor: String? let pageSize: Int } diff --git a/MLS/Data/Data/Repository/AuthAPIRepositoryImpl.swift b/MLS/Data/Data/Repository/AuthAPIRepositoryImpl.swift index 0cec54a0..d5bef287 100644 --- a/MLS/Data/Data/Repository/AuthAPIRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/AuthAPIRepositoryImpl.swift @@ -78,12 +78,12 @@ public class AuthAPIRepositoryImpl: AuthAPIRepository { public func reissueToken(refreshToken: String) -> Observable { let endPoint = AuthEndPoint.reIssueToken(refreshToken: refreshToken) - return provider.requestData(endPoint: endPoint, interceptor: authInterceptor).map { $0.toLoginDomain() } + return provider.requestData(endPoint: endPoint, interceptor: nil).map { $0.toLoginDomain() } } - public func fcmToken(credential: String, fcmToken: String?) -> Completable { - let endPoint = AuthEndPoint.fcmToken(credential: credential, body: FCMTokenBody(fcmToken: fcmToken)) - return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + public func fcmToken(fcmToken: String?) -> Completable { + let endPoint = AuthEndPoint.fcmToken(body: FCMTokenBody(fcmToken: fcmToken)) + return provider.requestData(endPoint: endPoint, interceptor: authInterceptor) } public func fetchJobList() -> Observable { diff --git a/MLS/Data/Data/Repository/BookmarkRepositoryImpl.swift b/MLS/Data/Data/Repository/BookmarkRepositoryImpl.swift index e2a9c371..144b7b86 100644 --- a/MLS/Data/Data/Repository/BookmarkRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/BookmarkRepositoryImpl.swift @@ -13,14 +13,16 @@ public class BookmarkRepositoryImpl: BookmarkRepository { self.tokenInterceptor = interceptor } - public func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Completable { + public func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Observable { let endPoint = BookmarkEndPoint.setBookmark(body: SetBookmarkQuery(bookmarkType: type.rawValue, resourceId: bookmarkId)) return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toDomain() } } - public func deleteBookmark(bookmarkId: Int) -> Completable { + public func deleteBookmark(bookmarkId: Int) -> Observable { let endPoint = BookmarkEndPoint.deleteBookmark(bookmarkId: bookmarkId) return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) + .map { $0.toBookmarkDomain() } } public func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> { diff --git a/MLS/Data/Data/Repository/CollectionAPIRepositoryImpl.swift b/MLS/Data/Data/Repository/CollectionAPIRepositoryImpl.swift index c1bb3d5c..e3f7ad2a 100644 --- a/MLS/Data/Data/Repository/CollectionAPIRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/CollectionAPIRepositoryImpl.swift @@ -31,29 +31,7 @@ public class CollectionAPIRepositoryImpl: CollectionAPIRepository { .map { $0.toDomain() } } -// public func addBookmarksToCollection(collectionId: Int, bookmarkIds: [Int]) -> Completable { -// let endPoint = CollectionEndPoint.addBookmarksToCollection(id: collectionId, body: AddBookmarkRequestBody(bookmarkIds: bookmarkIds)) -// return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) -// .catch { error in -// if let netErr = error as? NetworkError { -// switch netErr { -// case let .statusError(code, body): -// return .error(DomainHTTPError.httpStatus(code: code, message: body)) -// default: -// return .error(DomainHTTPError.unknown) -// } -// } else { -// return .error(DomainHTTPError.unknown) -// } -// } -// } -// -// public func addCollectionsToBookmark(bookmarkId: Int, collectionIds: [Int]) -> Completable { -// let endPoint = CollectionEndPoint.addCollectionsToBookmark(id: bookmarkId, body: AddCollectionRequestBody(collectionIds: collectionIds)) -// return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) -// } - - public func setCollectionName(collectionId: Int, name: String) -> Completable { + public func updateCollectionName(collectionId: Int, name: String) -> Completable { let endPoint = CollectionEndPoint.setCollectionName(id: collectionId, body: SetCollectionRequestBody(name: name)) return provider.requestData(endPoint: endPoint, interceptor: tokenInterceptor) } diff --git a/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift b/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift index e8ea829f..991699f3 100644 --- a/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift +++ b/MLS/Data/Data/Repository/UserDefaultsRepositoryImpl.swift @@ -1,6 +1,7 @@ -import DomainInterface import Foundation +import DomainInterface + import RxSwift public final class UserDefaultsRepositoryImpl: UserDefaultsRepository { diff --git a/MLS/Data/DataMock/Repository/AuthAPIRepositoryMock.swift b/MLS/Data/DataMock/Repository/AuthAPIRepositoryMock.swift index 587b54f9..2e524dbd 100644 --- a/MLS/Data/DataMock/Repository/AuthAPIRepositoryMock.swift +++ b/MLS/Data/DataMock/Repository/AuthAPIRepositoryMock.swift @@ -36,7 +36,7 @@ public class AuthAPIRepositoryMock: AuthAPIRepository { return Observable.just(.init(accessToken: "testToken", refreshToken: "testToken")) } - public func fcmToken(credential: String, fcmToken: String?) -> Completable { + public func fcmToken(fcmToken: String?) -> Completable { return .empty() } diff --git a/MLS/Domain/Domain/Interceptor/AuthInterceptor.swift b/MLS/Domain/Domain/Interceptor/AuthInterceptor.swift index 5496655d..b0e504e2 100644 --- a/MLS/Domain/Domain/Interceptor/AuthInterceptor.swift +++ b/MLS/Domain/Domain/Interceptor/AuthInterceptor.swift @@ -14,6 +14,7 @@ public final class AuthInterceptor: Interceptor { public func adapt(_ request: URLRequest) -> URLRequest { var request = request if case .success(let token) = tokenRepository.fetchToken(type: .accessToken) { + print("accessToken: \(token)") request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } return request @@ -33,7 +34,7 @@ public final class AuthInterceptor: Interceptor { let repo = authRepository() repo.reissueToken(refreshToken: refreshToken) .subscribe(onNext: { _ in - print("✅ reissue 완료 (저장은 UseCase 쪽에서 처리)") + print("✅ reissue 완료") }, onError: { error in print("❌ reissue 실패: \(error)") }) diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift index 60f51b83..47060dfb 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchAllAlarmUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchAllAlarmUseCaseImpl: FetchAllAlarmUseCase { self.repository = repository } - public func execute(cursor: [Int]?, pageSize: Int) -> Observable> { + public func execute(cursor: String?, pageSize: Int) -> Observable> { return repository.fetchAll(cursor: cursor, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift index f144a80b..410913be 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchNoticesUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchNoticesUseCaseImpl: FetchNoticesUseCase { self.repository = repository } - public func execute(cursor: [Int]?, pageSize: Int) -> Observable> { + public func execute(cursor: String?, pageSize: Int) -> Observable> { return repository.fetchNotices(cursor: cursor, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift index d76991c1..dd28aa8d 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOngoingEventsUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchOngoingEventsUseCaseImpl: FetchOngoingEventsUseCase { self.repository = repository } - public func execute(cursor: [Int]?, pageSize: Int) -> Observable> { + public func execute(cursor: String?, pageSize: Int) -> Observable> { return repository.fetchOngoingEvents(cursor: cursor, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift index 7fb8f1cd..f3571532 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchOutdatedEventsUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchOutdatedEventsUseCaseImpl: FetchOutdatedEventsUseCase { self.repository = repository } - public func execute(cursor: [Int]?, pageSize: Int) -> Observable> { + public func execute(cursor: String?, pageSize: Int) -> Observable> { return repository.fetchOutdatedEvents(cursor: cursor, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift index 345fca46..2817b0ef 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Alarm/FetchPatchNotesUseCaseImpl.swift @@ -11,7 +11,7 @@ public class FetchPatchNotesUseCaseImpl: FetchPatchNotesUseCase { self.repository = repository } - public func execute(cursor: [Int]?, pageSize: Int) -> Observable> { + public func execute(cursor: String?, pageSize: Int) -> Observable> { return repository.fetchPatchNotes(cursor: cursor, pageSize: pageSize) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithAppleUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithAppleUseCaseImpl.swift index fbff7c74..1819bf20 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithAppleUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithAppleUseCaseImpl.swift @@ -23,14 +23,25 @@ public class LoginWithAppleUseCaseImpl: LoginWithAppleUseCase { let saveRefresh = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) let savePlatform = self.userDefaultsRepository.savePlatform(platform: .apple) - switch (saveAccess, saveRefresh) { - case (.success, .success): - return savePlatform.andThen(Observable.just(response)) - default: - return Observable.error( - TokenRepositoryError.dataConversionError(message: "Failed to save tokens") - ) + guard case (.success, .success) = (saveAccess, saveRefresh) else { + return Observable.error(TokenRepositoryError.dataConversionError(message: "Failed to save tokens")) } + + var fcmToken: String? + if case .success(let token) = self.tokenRepository.fetchToken(type: .fcmToken) { + fcmToken = token + } + + let fcmUpdate = if let fcmToken { + self.authRepository.fcmToken(fcmToken: fcmToken) + .catch { error in + print("FCM token update failed: \(error)") + return .empty() + } + } else { + Completable.empty() + } + return fcmUpdate.andThen(savePlatform).andThen(Observable.just(response)) } .catch { error in Observable.error(error) diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithKakaoUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithKakaoUseCaseImpl.swift index b19f1f53..db4e8719 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithKakaoUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LoginWithKakaoUseCaseImpl.swift @@ -15,7 +15,6 @@ public class LoginWithKakaoUseCaseImpl: LoginWithKakaoUseCase { self.userDefaultsRepository = userDefaultsRepository } - // 로그인할때 토큰 저장 필요 public func execute(credential: Credential) -> Observable { return authRepository.loginWithKakao(credential: credential) .flatMap { response -> Observable in @@ -23,15 +22,25 @@ public class LoginWithKakaoUseCaseImpl: LoginWithKakaoUseCase { let saveRefresh = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) let savePlatform = self.userDefaultsRepository.savePlatform(platform: .kakao) - // ✅ 모든 저장 결과 확인 - switch (saveAccess, saveRefresh) { - case (.success, .success): - return savePlatform.andThen(Observable.just(response)) - default: - return Observable.error( - TokenRepositoryError.dataConversionError(message: "Failed to save tokens") - ) + guard case (.success, .success) = (saveAccess, saveRefresh) else { + return Observable.error(TokenRepositoryError.dataConversionError(message: "Failed to save tokens")) } + + var fcmToken: String? + if case .success(let token) = self.tokenRepository.fetchToken(type: .fcmToken) { + fcmToken = token + } + + let fcmUpdate = if let fcmToken { + self.authRepository.fcmToken(fcmToken: fcmToken) + .catch { error in + print("FCM token update failed: \(error)") + return .empty() + } + } else { + Completable.empty() + } + return fcmUpdate.andThen(savePlatform).andThen(Observable.just(response)) } .catch { error in Observable.error(error) diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LogoutUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LogoutUseCaseImpl.swift index ad8f2054..0ece074f 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LogoutUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/LogoutUseCaseImpl.swift @@ -18,13 +18,24 @@ public class LogoutUseCaseImpl: LogoutUseCase { return Disposables.create() } - switch self.repository.deleteToken(type: .accessToken) { - case .success: - completable(.completed) - case .failure(let error): - completable(.error(error)) + let deleteAccess = self.repository.deleteToken(type: .accessToken) + let deleteRefresh = self.repository.deleteToken(type: .refreshToken) + + guard case .success = deleteAccess, case .success = deleteRefresh else { + completable(.error(NSError(domain: "LogoutError", code: -1, userInfo: nil))) + return Disposables.create() + } + + var fcmToken: String? + if case .success(let token) = self.repository.fetchToken(type: .fcmToken) { + fcmToken = token + } + + if fcmToken != nil { + _ = self.repository.deleteToken(type: .fcmToken) } + completable(.completed) return Disposables.create() } } diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/PutFCMTokenUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/PutFCMTokenUseCaseImpl.swift index 38138859..8f08eba0 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/PutFCMTokenUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/PutFCMTokenUseCaseImpl.swift @@ -11,7 +11,7 @@ public class PutFCMTokenUseCaseImpl: PutFCMTokenUseCase { self.repository = repository } - public func execute(credential: String, fcmToken: String?) -> Completable { - return repository.fcmToken(credential: credential, fcmToken: fcmToken) + public func execute(fcmToken: String?) -> Completable { + return repository.fcmToken(fcmToken: fcmToken) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithAppleUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithAppleUseCaseImpl.swift index 2aa72b99..6a1ed88a 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithAppleUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithAppleUseCaseImpl.swift @@ -4,14 +4,44 @@ import DomainInterface import RxSwift -public class SignUpWithAppleUseCaseImpl: SignUpWithAppleUseCase { - private var repository: AuthAPIRepository +public final class SignUpWithAppleUseCaseImpl: SignUpWithAppleUseCase { + private let authRepository: AuthAPIRepository + private let tokenRepository: TokenRepository + private let userDefaultsRepository: UserDefaultsRepository - public init(repository: AuthAPIRepository) { - self.repository = repository + public init( + authRepository: AuthAPIRepository, + tokenRepository: TokenRepository, + userDefaultsRepository: UserDefaultsRepository + ) { + self.authRepository = authRepository + self.tokenRepository = tokenRepository + self.userDefaultsRepository = userDefaultsRepository } - public func execute(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { - return repository.signUpWithApple(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) + public func execute( + credential: Credential, + isMarketingAgreement: Bool, + fcmToken: String? + ) -> Observable { + return authRepository + .signUpWithApple(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) + .flatMap { response -> Observable in + let saveAccess = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) + let saveRefresh = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) + let savePlatform = self.userDefaultsRepository.savePlatform(platform: .apple) + + switch (saveAccess, saveRefresh) { + case (.success, .success): + return savePlatform.andThen(Observable.just(response)) + default: + return Observable.error( + TokenRepositoryError.dataConversionError(message: "Failed to save tokens") + ) + } + } + .catch { error in + Observable.error(error) + } } } diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithKakaoUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithKakaoUseCaseImpl.swift index bcf61e64..7316776b 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithKakaoUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/SignUpWithKakaoUseCaseImpl.swift @@ -1,17 +1,45 @@ -import Foundation - import DomainInterface - +import Foundation import RxSwift -public class SignUpWithKakaoUseCaseImpl: SignUpWithKakaoUseCase { - private var repository: AuthAPIRepository +public final class SignUpWithKakaoUseCaseImpl: SignUpWithKakaoUseCase { + private let authRepository: AuthAPIRepository + private let tokenRepository: TokenRepository + private let userDefaultsRepository: UserDefaultsRepository - public init(repository: AuthAPIRepository) { - self.repository = repository + public init( + authRepository: AuthAPIRepository, + tokenRepository: TokenRepository, + userDefaultsRepository: UserDefaultsRepository + ) { + self.authRepository = authRepository + self.tokenRepository = tokenRepository + self.userDefaultsRepository = userDefaultsRepository } - public func execute(credential: Credential, isMarketingAgreement: Bool, fcmToken: String?) -> Observable { - return repository.signUpWithKakao(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) + public func execute( + credential: Credential, + isMarketingAgreement: Bool, + fcmToken: String? + ) -> Observable { + return authRepository + .signUpWithKakao(credential: credential, isMarketingAgreement: isMarketingAgreement, fcmToken: fcmToken) + .flatMap { response -> Observable in + let saveAccess = self.tokenRepository.saveToken(type: .accessToken, value: response.accessToken) + let saveRefresh = self.tokenRepository.saveToken(type: .refreshToken, value: response.refreshToken) + let savePlatform = self.userDefaultsRepository.savePlatform(platform: .kakao) + + switch (saveAccess, saveRefresh) { + case (.success, .success): + return savePlatform.andThen(Observable.just(response)) + default: + return Observable.error( + TokenRepositoryError.dataConversionError(message: "Failed to save tokens") + ) + } + } + .catch { error in + Observable.error(error) + } } } diff --git a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/WithdrawUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/WithdrawUseCaseImpl.swift index 3ac9a5e8..fc6b314d 100644 --- a/MLS/Domain/Domain/UseCaseImpl/AuthAPI/WithdrawUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/AuthAPI/WithdrawUseCaseImpl.swift @@ -20,8 +20,7 @@ public class WithdrawUseCaseImpl: WithdrawUseCase { let results: [Result] = [ self.tokenRepository.deleteToken(type: .accessToken), - self.tokenRepository.deleteToken(type: .refreshToken), - self.tokenRepository.deleteToken(type: .fcmToken) + self.tokenRepository.deleteToken(type: .refreshToken) ] for result in results { diff --git a/MLS/Domain/Domain/UseCaseImpl/Bookmark/SetBookmarkUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Bookmark/SetBookmarkUseCaseImpl.swift index f7d1cb85..0a45e333 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Bookmark/SetBookmarkUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Bookmark/SetBookmarkUseCaseImpl.swift @@ -9,10 +9,12 @@ public final class SetBookmarkUseCaseImpl: SetBookmarkUseCase { self.repository = repository } - public func execute(bookmarkId: Int, isBookmark: IsBookmark) -> Completable { + public func execute(bookmarkId: Int, isBookmark: IsBookmark) -> Observable { switch isBookmark { case .set(let type): - return repository.setBookmark(bookmarkId: bookmarkId, type: type) + return repository + .setBookmark(bookmarkId: bookmarkId, type: type) + .map { Optional($0) } case .delete: return repository.deleteBookmark(bookmarkId: bookmarkId) } diff --git a/MLS/Domain/Domain/UseCaseImpl/Collection/AddBookmarksToCollectionUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Collection/AddBookmarksToCollectionUseCaseImpl.swift deleted file mode 100644 index ea42c345..00000000 --- a/MLS/Domain/Domain/UseCaseImpl/Collection/AddBookmarksToCollectionUseCaseImpl.swift +++ /dev/null @@ -1,15 +0,0 @@ -// import DomainInterface -// -// import RxSwift -// -// public final class AddBookmarksToCollectionUseCaseImpl: AddBookmarksToCollectionUseCase { -// private let repository: CollectionAPIRepository -// -// public init(repository: CollectionAPIRepository) { -// self.repository = repository -// } -// -// public func execute(collectionId: Int, bookmarkIds: [Int]) -> Completable { -// return repository.addBookmarksToCollection(collectionId: collectionId, bookmarkIds: bookmarkIds) -// } -// } diff --git a/MLS/Domain/Domain/UseCaseImpl/Collection/AddCollectionsToBookmarkUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Collection/AddCollectionsToBookmarkUseCaseImpl.swift deleted file mode 100644 index f1ea702a..00000000 --- a/MLS/Domain/Domain/UseCaseImpl/Collection/AddCollectionsToBookmarkUseCaseImpl.swift +++ /dev/null @@ -1,14 +0,0 @@ -// import DomainInterface -// import RxSwift -// -// public final class AddCollectionsToBookmarkUseCaseImpl: AddCollectionsToBookmarkUseCase { -// private let repository: CollectionAPIRepository -// -// public init(repository: CollectionAPIRepository) { -// self.repository = repository -// } -// -// public func execute(bookmarkId: Int, collectionIds: [Int]) -> Completable { -// return repository.addCollectionsToBookmark(bookmarkId: bookmarkId, collectionIds: collectionIds) -// } -// } diff --git a/MLS/Domain/Domain/UseCaseImpl/Collection/SetCollectionUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/Collection/UpdateCollectionUseCaseImpl.swift similarity index 62% rename from MLS/Domain/Domain/UseCaseImpl/Collection/SetCollectionUseCaseImpl.swift rename to MLS/Domain/Domain/UseCaseImpl/Collection/UpdateCollectionUseCaseImpl.swift index 7e9912ac..f8dc3727 100644 --- a/MLS/Domain/Domain/UseCaseImpl/Collection/SetCollectionUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/Collection/UpdateCollectionUseCaseImpl.swift @@ -2,7 +2,7 @@ import DomainInterface import RxSwift -public final class SetCollectionUseCaseImpl: SetCollectionUseCase { +public final class UpdateCollectionUseCaseImpl: UpdateCollectionUseCase { private let repository: CollectionAPIRepository public init(repository: CollectionAPIRepository) { @@ -10,6 +10,6 @@ public final class SetCollectionUseCaseImpl: SetCollectionUseCase { } public func execute(collectionId: Int, name: String) -> Completable { - return repository.setCollectionName(collectionId: collectionId, name: name) + return repository.updateCollectionName(collectionId: collectionId, name: name) } } diff --git a/MLS/Domain/Domain/UseCaseImpl/RecentSearch/RecentSearchAddUseCaseImpl.swift b/MLS/Domain/Domain/UseCaseImpl/RecentSearch/RecentSearchAddUseCaseImpl.swift index a56d8544..929ec5f6 100644 --- a/MLS/Domain/Domain/UseCaseImpl/RecentSearch/RecentSearchAddUseCaseImpl.swift +++ b/MLS/Domain/Domain/UseCaseImpl/RecentSearch/RecentSearchAddUseCaseImpl.swift @@ -11,7 +11,7 @@ public class RecentSearchAddUseCaseImpl: RecentSearchAddUseCase { } public func add(keyword: String) -> Completable { + guard !keyword.isEmpty else { return .empty() } return repository.addRecentSearch(keyword: keyword) } - } diff --git a/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift b/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift index 365d0e59..78b27ee3 100644 --- a/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/Alarm/AlarmResponse.swift @@ -4,9 +4,9 @@ public struct AlarmResponse: Equatable { public let type: String public let title: String public let link: String - public let date: [Int] + public let date: String - public init(type: String, title: String, link: String, date: [Int]) { + public init(type: String, title: String, link: String, date: String) { self.type = type self.title = title self.link = link @@ -18,10 +18,10 @@ public struct AllAlarmResponse: Equatable { public let type: String public let title: String public let link: String - public let date: [Int] - public let alreadyRead: Bool + public let date: String + public var alreadyRead: Bool - public init(type: String, title: String, link: String, date: [Int], alreadyRead: Bool) { + public init(type: String, title: String, link: String, date: String, alreadyRead: Bool) { self.type = type self.title = title self.link = link diff --git a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailItemResponse.swift b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailItemResponse.swift index 5ef9c4ff..bc1f2064 100644 --- a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailItemResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailItemResponse.swift @@ -1,5 +1,5 @@ public struct DictionaryDetailItemResponse: Equatable { - public let itemId: Int? + public let itemId: Int public let nameKr: String? public let nameEn: String? public let descriptionText: String? @@ -11,10 +11,10 @@ public struct DictionaryDetailItemResponse: Equatable { public let requiredStats: RequiredStats? // 요구 스탯 public let equipmentStats: EquipmentStats? // 착용하면 올라가는 스탯 public let scrollDetail: ScrollDetail? // 주문서 상세정보 - public let bookmarkId: Int? + public var bookmarkId: Int? public init( - itemId: Int?, + itemId: Int, nameKr: String?, nameEn: String?, descriptionText: String?, diff --git a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMapResponse.swift b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMapResponse.swift index 8449d2d8..71881911 100644 --- a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMapResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMapResponse.swift @@ -1,5 +1,5 @@ public struct DictionaryDetailMapResponse: Equatable { - public let mapId: Int? + public let mapId: Int public let nameKr: String? public let nameEn: String? public let regionName: String? @@ -7,9 +7,9 @@ public struct DictionaryDetailMapResponse: Equatable { public let topRegionName: String? public let mapUrl: String? public let iconUrl: String? - public let bookmarkId: Int? + public var bookmarkId: Int? - public init(mapId: Int?, nameKr: String?, nameEn: String?, regionName: String?, detailName: String?, topRegionName: String?, mapUrl: String?, iconUrl: String?, bookmarkId: Int?) { + public init(mapId: Int, nameKr: String?, nameEn: String?, regionName: String?, detailName: String?, topRegionName: String?, mapUrl: String?, iconUrl: String?, bookmarkId: Int?) { self.mapId = mapId self.nameKr = nameKr self.nameEn = nameEn diff --git a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMonsterResponse.swift b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMonsterResponse.swift index 751f1b17..90ced802 100644 --- a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMonsterResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailMonsterResponse.swift @@ -19,7 +19,7 @@ public struct DictionaryDetailMonsterResponse: Equatable { public let mesoDropAmount: Int? public let mesoDropRate: Int? public let typeEffectiveness: Effectiveness? - public let bookmarkId: Int? + public var bookmarkId: Int? public init( monsterId: Int, diff --git a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailNpcResponse.swift b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailNpcResponse.swift index 3c7cf79f..02962f19 100644 --- a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailNpcResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailNpcResponse.swift @@ -3,7 +3,7 @@ public struct DictionaryDetailNpcResponse: Equatable { public let nameKr: String public let nameEn: String public let iconUrlDetail: String? - public let bookmarkId: Int? + public var bookmarkId: Int? public init(npcId: Int, nameKr: String, nameEn: String, iconUrlDetail: String?, bookmarkId: Int?) { self.npcId = npcId diff --git a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailQuestResponse.swift b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailQuestResponse.swift index 753fbc21..6c065792 100644 --- a/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailQuestResponse.swift +++ b/MLS/Domain/DomainInterface/Entity/DictionaryDetail/DictionaryDetailQuestResponse.swift @@ -1,5 +1,5 @@ public struct DictionaryDetailQuestResponse: Equatable { - public let questId: Int? + public let questId: Int public let titlePrefix: String? public let nameKr: String? public let nameEn: String? @@ -16,10 +16,10 @@ public struct DictionaryDetailQuestResponse: Equatable { public let rewardItems: [RewardItem]? public let requirements: [Requirements]? public let allowedJobs: [AllowedJob]? - public let bookmarkId: Int? + public var bookmarkId: Int? public init( - questId: Int?, + questId: Int, titlePrefix: String?, nameKr: String?, nameEn: String?, diff --git a/MLS/Domain/DomainInterface/Entity/Filter/SortType.swift b/MLS/Domain/DomainInterface/Entity/Filter/SortType.swift index 56949996..0adf976c 100644 --- a/MLS/Domain/DomainInterface/Entity/Filter/SortType.swift +++ b/MLS/Domain/DomainInterface/Entity/Filter/SortType.swift @@ -16,6 +16,8 @@ public enum SortType: String { // 정렬 키 - 이름, 레벨, 경험치 public var sortKey: String { switch self { + case .latest: + return "createdAt" case .korean: return "name" case .levelASC, .levelDESC: @@ -35,7 +37,7 @@ public enum SortType: String { switch self { case .expASC, .levelASC, .korean: return "asc" - case .expDESC, .levelDESC, .mostDrop, .mostAppear: + case .expDESC, .levelDESC, .mostDrop, .mostAppear, .latest: return "desc" default: return "" diff --git a/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift b/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift index ea980b26..4c3c303b 100644 --- a/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift +++ b/MLS/Domain/DomainInterface/Repository/AlarmAPIRepository.swift @@ -3,15 +3,15 @@ import Foundation import RxSwift public protocol AlarmAPIRepository { - func fetchPatchNotes(cursor: [Int]?, pageSize: Int) -> Observable> + func fetchPatchNotes(cursor: String?, pageSize: Int) -> Observable> - func fetchNotices(cursor: [Int]?, pageSize: Int) -> Observable> + func fetchNotices(cursor: String?, pageSize: Int) -> Observable> - func fetchOutdatedEvents(cursor: [Int]?, pageSize: Int) -> Observable> + func fetchOutdatedEvents(cursor: String?, pageSize: Int) -> Observable> - func fetchOngoingEvents(cursor: [Int]?, pageSize: Int) -> Observable> + func fetchOngoingEvents(cursor: String?, pageSize: Int) -> Observable> - func fetchAll(cursor: [Int]?, pageSize: Int) -> Observable> + func fetchAll(cursor: String?, pageSize: Int) -> Observable> func setRead(alarmLink: String) -> Completable } diff --git a/MLS/Domain/DomainInterface/Repository/AuthAPIRepository.swift b/MLS/Domain/DomainInterface/Repository/AuthAPIRepository.swift index 1b0b28c6..2e8b1224 100644 --- a/MLS/Domain/DomainInterface/Repository/AuthAPIRepository.swift +++ b/MLS/Domain/DomainInterface/Repository/AuthAPIRepository.swift @@ -54,7 +54,7 @@ public protocol AuthAPIRepository { /// - Returns: 토큰 갱신 응답을 담은 Observable func reissueToken(refreshToken: String) -> Observable - func fcmToken(credential: String, fcmToken: String?) -> Completable + func fcmToken(fcmToken: String?) -> Completable func updateMarketingAgreement(credential: String, isMarketingAgreement: Bool) -> Completable diff --git a/MLS/Domain/DomainInterface/Repository/BookmarkRepository.swift b/MLS/Domain/DomainInterface/Repository/BookmarkRepository.swift index e1b7d1a7..7730602d 100644 --- a/MLS/Domain/DomainInterface/Repository/BookmarkRepository.swift +++ b/MLS/Domain/DomainInterface/Repository/BookmarkRepository.swift @@ -3,9 +3,9 @@ import Foundation import RxSwift public protocol BookmarkRepository { - func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Completable + func setBookmark(bookmarkId: Int, type: DictionaryItemType) -> Observable - func deleteBookmark(bookmarkId: Int) -> Completable + func deleteBookmark(bookmarkId: Int) -> Observable func fetchBookmark(sort: String?) -> Observable<[BookmarkResponse]> diff --git a/MLS/Domain/DomainInterface/Repository/CollectionAPIRepository.swift b/MLS/Domain/DomainInterface/Repository/CollectionAPIRepository.swift index ecdf7645..a6033351 100644 --- a/MLS/Domain/DomainInterface/Repository/CollectionAPIRepository.swift +++ b/MLS/Domain/DomainInterface/Repository/CollectionAPIRepository.swift @@ -10,11 +10,7 @@ public protocol CollectionAPIRepository { // 컬렉션 상세 조회 func fetchCollectionUseCase(id: Int) -> Observable<[BookmarkResponse]> -// func addBookmarksToCollection(collectionId: Int, bookmarkIds: [Int]) -> Completable - -// func addCollectionsToBookmark(bookmarkId: Int, collectionIds: [Int]) -> Completable - - func setCollectionName(collectionId: Int, name: String) -> Completable + func updateCollectionName(collectionId: Int, name: String) -> Completable func deleteCollection(collectionId: Int) -> Completable diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift index 72a34087..41206233 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchAllAlarmUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchAllAlarmUseCase { - func execute(cursor: [Int]?, pageSize: Int) -> Observable> + func execute(cursor: String?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift index 9a3d4a0f..f5d75581 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchNoticesUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchNoticesUseCase { - func execute(cursor: [Int]?, pageSize: Int) -> Observable> + func execute(cursor: String?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift index 31d6427f..80303048 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOngoingEventsUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchOngoingEventsUseCase { - func execute(cursor: [Int]?, pageSize: Int) -> Observable> + func execute(cursor: String?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift index 34618f8a..959663ea 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchOutdatedEventsUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchOutdatedEventsUseCase { - func execute(cursor: [Int]?, pageSize: Int) -> Observable> + func execute(cursor: String?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift index b8dd6886..62d27587 100644 --- a/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Alarm/FetchPatchNotesUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol FetchPatchNotesUseCase { - func execute(cursor: [Int]?, pageSize: Int) -> Observable> + func execute(cursor: String?, pageSize: Int) -> Observable> } diff --git a/MLS/Domain/DomainInterface/UseCase/AuthAPI/PutFCMTokenUseCase.swift b/MLS/Domain/DomainInterface/UseCase/AuthAPI/PutFCMTokenUseCase.swift index b3fd3347..283f676c 100644 --- a/MLS/Domain/DomainInterface/UseCase/AuthAPI/PutFCMTokenUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/AuthAPI/PutFCMTokenUseCase.swift @@ -1,5 +1,5 @@ import RxSwift public protocol PutFCMTokenUseCase { - func execute(credential: String, fcmToken: String?) -> Completable + func execute(fcmToken: String?) -> Completable } diff --git a/MLS/Domain/DomainInterface/UseCase/Bookmark/SetBookmarkUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Bookmark/SetBookmarkUseCase.swift index e1180e62..94117213 100644 --- a/MLS/Domain/DomainInterface/UseCase/Bookmark/SetBookmarkUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Bookmark/SetBookmarkUseCase.swift @@ -1,7 +1,7 @@ import RxSwift public protocol SetBookmarkUseCase { - func execute(bookmarkId: Int, isBookmark: IsBookmark) -> Completable + func execute(bookmarkId: Int, isBookmark: IsBookmark) -> Observable } public enum IsBookmark { diff --git a/MLS/Domain/DomainInterface/UseCase/Collection/SetCollectionUseCase.swift b/MLS/Domain/DomainInterface/UseCase/Collection/UpdateCollectionUseCase.swift similarity index 66% rename from MLS/Domain/DomainInterface/UseCase/Collection/SetCollectionUseCase.swift rename to MLS/Domain/DomainInterface/UseCase/Collection/UpdateCollectionUseCase.swift index 39cea79d..efeb3308 100644 --- a/MLS/Domain/DomainInterface/UseCase/Collection/SetCollectionUseCase.swift +++ b/MLS/Domain/DomainInterface/UseCase/Collection/UpdateCollectionUseCase.swift @@ -1,5 +1,5 @@ import RxSwift -public protocol SetCollectionUseCase { +public protocol UpdateCollectionUseCase { func execute(collectionId: Int, name: String) -> Completable } diff --git a/MLS/MLS/Application/AppDelegate.swift b/MLS/MLS/Application/AppDelegate.swift index 0ffa7712..909c3fae 100644 --- a/MLS/MLS/Application/AppDelegate.swift +++ b/MLS/MLS/Application/AppDelegate.swift @@ -30,13 +30,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .LaunchOptionsKey: Any]? ) -> Bool { // MARK: - UserNotification Set - FirebaseApp.configure() // Firebase Set - Messaging.messaging().delegate = self // 파이어베이스 Meesaging 설정 - UNUserNotificationCenter.current().delegate = self // NotificationCenter Delegate + FirebaseApp.configure() // Firebase Set + Messaging.messaging().delegate = self // 파이어베이스 Meesaging 설정 + UNUserNotificationCenter.current().delegate = self // NotificationCenter Delegate // MARK: - Modules Set - ImageLoader.shared.configure.diskCacheCountLimit = 10 // ImageLoader - FontManager.registerFonts() // FontManager + ImageLoader.shared.configure.diskCacheCountLimit = 10 // ImageLoader + FontManager.registerFonts() // FontManager // MARK: - KakaoSDK Set let kakaoNativeAppKey: String = @@ -70,9 +70,9 @@ extension AppDelegate: UNUserNotificationCenterDelegate, MessagingDelegate { _ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: - @escaping ( - UNNotificationPresentationOptions - ) -> Void + @escaping ( + UNNotificationPresentationOptions + ) -> Void ) { completionHandler([.list, .banner]) } @@ -88,26 +88,37 @@ extension AppDelegate: UNUserNotificationCenterDelegate, MessagingDelegate { object: nil, userInfo: dataDict ) - let tokenUseCase = DIContainer.resolve( - type: SaveTokenToLocalUseCase.self - ) - let result = tokenUseCase.execute( - type: .fcmToken, - value: fcmToken ?? "" - ) + guard let fcmToken = fcmToken else { + os_log("FCM token is nil") + return + } - switch result { + let saveTokenUseCase = DIContainer.resolve(type: SaveTokenToLocalUseCase.self) + let saveResult = saveTokenUseCase.execute(type: .fcmToken, value: fcmToken) + + switch saveResult { case .success: - os_log("✅ fcmToken Save Success Token: \(fcmToken ?? "")") + os_log("fcmToken Save Success: \(fcmToken)") case .failure: - os_log("⚠️ fcmToken Save Failure") + os_log("fcmToken Save Failure") + } + + let fetchTokenUseCase = DIContainer.resolve(type: FetchTokenFromLocalUseCase.self) + let putFCMTokenUseCase = DIContainer.resolve(type: PutFCMTokenUseCase.self) + + if case .success(let accessToken) = fetchTokenUseCase.execute(type: .accessToken), + !accessToken.isEmpty { + _ = putFCMTokenUseCase.execute(fcmToken: fcmToken) + os_log("Request to update FCM token on server") + } else { + os_log("Not logged in yet, skipping FCM update to server") } } } // MARK: - registerDependencies -extension AppDelegate { - fileprivate func registerDependencies() { +private extension AppDelegate { + func registerDependencies() { registerProvider() registerRepository() registerUseCase() @@ -130,7 +141,7 @@ extension AppDelegate { } } - fileprivate func registerProvider() { + func registerProvider() { DIContainer.register(type: NetworkProvider.self) { NetworkProviderImpl() } @@ -159,7 +170,7 @@ extension AppDelegate { } } - fileprivate func registerRepository() { + func registerRepository() { DIContainer.register(type: AuthAPIRepository.self) { AuthAPIRepositoryImpl( provider: DIContainer.resolve(type: NetworkProvider.self), @@ -205,7 +216,7 @@ extension AppDelegate { } } - fileprivate func registerUseCase() { + func registerUseCase() { DIContainer.register( type: FetchSocialCredentialUseCase.self, name: "kakao" @@ -265,12 +276,16 @@ extension AppDelegate { } DIContainer.register(type: SignUpWithAppleUseCase.self) { SignUpWithAppleUseCaseImpl( - repository: DIContainer.resolve(type: AuthAPIRepository.self) + authRepository: DIContainer.resolve(type: AuthAPIRepository.self), + tokenRepository: DIContainer.resolve(type: TokenRepository.self), + userDefaultsRepository: DIContainer.resolve(type: UserDefaultsRepository.self) ) } DIContainer.register(type: SignUpWithKakaoUseCase.self) { SignUpWithKakaoUseCaseImpl( - repository: DIContainer.resolve(type: AuthAPIRepository.self) + authRepository: DIContainer.resolve(type: AuthAPIRepository.self), + tokenRepository: DIContainer.resolve(type: TokenRepository.self), + userDefaultsRepository: DIContainer.resolve(type: UserDefaultsRepository.self) ) } DIContainer.register(type: UpdateUserInfoUseCase.self) { @@ -641,8 +656,8 @@ extension AppDelegate { ) ) } - DIContainer.register(type: SetCollectionUseCase.self) { - SetCollectionUseCaseImpl( + DIContainer.register(type: UpdateCollectionUseCase.self) { + UpdateCollectionUseCaseImpl( repository: DIContainer.resolve( type: CollectionAPIRepository.self ) @@ -674,7 +689,7 @@ extension AppDelegate { } } - fileprivate func registerFactory() { + func registerFactory() { DIContainer.register(type: ItemFilterBottomSheetFactory.self) { ItemFilterBottomSheetFactoryImpl() } @@ -690,7 +705,7 @@ extension AppDelegate { type: CreateCollectionListUseCase.self ), setCollectionUseCase: DIContainer.resolve( - type: SetCollectionUseCase.self + type: UpdateCollectionUseCase.self ) ) } @@ -821,7 +836,7 @@ extension AppDelegate { type: FetchDictionaryQuestListUseCase.self ), dictionaryNpcListItemUseCase: - DIContainer + DIContainer .resolve(type: FetchDictionaryNpcListUseCase.self), dictionaryListItemUseCase: DIContainer.resolve( type: FetchDictionaryMonsterListUseCase.self @@ -856,11 +871,12 @@ extension AppDelegate { type: FetchDictionaryListCountUseCase.self ), dictionaryMainListFactory: - DIContainer + DIContainer .resolve(type: DictionaryMainListFactory.self), dictionarySearchListUseCase: DIContainer.resolve( type: FetchDictionarySearchListUseCase.self - ) + ), recentSearchAddUseCase: DIContainer.resolve( + type: RecentSearchAddUseCase.self) ) } DIContainer.register(type: DictionarySearchFactory.self) { @@ -872,7 +888,7 @@ extension AppDelegate { type: RecentSearchAddUseCase.self ), searchResultFactory: - DIContainer + DIContainer .resolve(type: DictionarySearchResultFactory.self), recentSearchFetchUseCase: DIContainer.resolve( type: RecentSearchFetchUseCase.self @@ -899,7 +915,9 @@ extension AppDelegate { ), fetchProfileUseCase: DIContainer.resolve( type: FetchProfileUseCase.self - ) + ), checkNotificationPermissionUseCase: DIContainer.resolve(type: CheckNotificationPermissionUseCase.self), + setReadUseCase: DIContainer.resolve( + type: SetReadUseCase.self) ) } DIContainer.register(type: DictionaryMainViewFactory.self) { @@ -922,13 +940,13 @@ extension AppDelegate { DIContainer.register(type: OnBoardingNotificationSheetFactory.self) { OnBoardingNotificationSheetFactoryImpl( checkNotificationPermissionUseCase: - DIContainer + DIContainer .resolve(type: CheckNotificationPermissionUseCase.self), openNotificationSettingUseCase: - DIContainer + DIContainer .resolve(type: OpenNotificationSettingUseCase.self), updateNotificationAgreementUseCase: - DIContainer + DIContainer .resolve(type: UpdateNotificationAgreementUseCase.self), updateUserInfoUseCase: DIContainer.resolve( type: UpdateUserInfoUseCase.self @@ -980,9 +998,6 @@ extension AppDelegate { signUpWithAppleUseCase: DIContainer.resolve( type: SignUpWithAppleUseCase.self ), - saveTokenUseCase: DIContainer.resolve( - type: SaveTokenToLocalUseCase.self - ), fetchTokenUseCase: DIContainer.resolve( type: FetchTokenFromLocalUseCase.self ), @@ -1006,25 +1021,25 @@ extension AppDelegate { DIContainer.register(type: BookmarkMainFactory.self) { BookmarkMainFactoryImpl( setBookmarkUseCase: - DIContainer + DIContainer .resolve(type: SetBookmarkUseCase.self), checkLoginUseCase: - DIContainer + DIContainer .resolve(type: CheckLoginUseCase.self), fetchVisitBookmarkUseCase: - DIContainer + DIContainer .resolve(type: FetchVisitBookmarkUseCase.self), onBoardingFactory: - DIContainer + DIContainer .resolve(type: BookmarkOnBoardingFactory.self), bookmarkListFactory: - DIContainer + DIContainer .resolve(type: BookmarkListFactory.self), collectionListFactory: - DIContainer + DIContainer .resolve(type: CollectionListFactory.self), searchFactory: - DIContainer + DIContainer .resolve(type: DictionarySearchFactory.self), notificationFactory: DIContainer.resolve( type: DictionaryNotificationFactory.self @@ -1097,35 +1112,35 @@ extension AppDelegate { type: CollectionDetailFactory.self ), sortedBottomSheetFactory: - DIContainer + DIContainer .resolve(type: SortedBottomSheetFactory.self) ) } DIContainer.register(type: CollectionDetailFactory.self) { CollectionDetailFactoryImpl( bookmarkModalFactory: - DIContainer + DIContainer .resolve(type: BookmarkModalFactory.self), collectionSettingFactory: - DIContainer + DIContainer .resolve(type: CollectionSettingFactory.self), addCollectionFactory: - DIContainer + DIContainer .resolve(type: AddCollectionFactory.self), collectionEditFactory: - DIContainer + DIContainer .resolve(type: CollectionEditFactory.self), dictionaryDetailFactory: - DIContainer + DIContainer .resolve(type: DictionaryDetailFactory.self), setBookmarkUseCase: - DIContainer + DIContainer .resolve(type: SetBookmarkUseCase.self), fetchCollectionUseCase: DIContainer.resolve( type: FetchCollectionUseCase.self ), deleteCollectionUseCase: - DIContainer + DIContainer .resolve(type: DeleteCollectionUseCase.self), addCollectionAndBookmarkUseCase: DIContainer.resolve( type: AddCollectionAndBookmarkUseCase.self @@ -1149,16 +1164,16 @@ extension AppDelegate { MyPageMainFactoryImpl( loginFactory: DIContainer.resolve(type: LoginFactory.self), setProfileFactory: - DIContainer + DIContainer .resolve(type: SetProfileFactory.self), customerSupportFactory: - DIContainer + DIContainer .resolve(type: CustomerSupportFactory.self), notificationSettingFactory: - DIContainer + DIContainer .resolve(type: NotificationSettingFactory.self), setCharacterFactory: - DIContainer + DIContainer .resolve(type: SetCharacterFactory.self), fetchProfileUseCase: DIContainer.resolve( type: FetchProfileUseCase.self @@ -1207,16 +1222,16 @@ extension AppDelegate { DIContainer.register(type: SetCharacterFactory.self) { SetCharacterFactoryImpl( checkEmptyUseCase: - DIContainer + DIContainer .resolve(type: CheckEmptyLevelAndRoleUseCase.self), checkValidLevelUseCase: - DIContainer + DIContainer .resolve(type: CheckValidLevelUseCase.self), fetchJobListUseCase: - DIContainer + DIContainer .resolve(type: FetchJobListUseCase.self), updateUserInfoUseCase: - DIContainer + DIContainer .resolve(type: UpdateUserInfoUseCase.self) ) } diff --git a/MLS/MLS/Application/SceneDelegate.swift b/MLS/MLS/Application/SceneDelegate.swift index 7e183d01..5e0d0292 100644 --- a/MLS/MLS/Application/SceneDelegate.swift +++ b/MLS/MLS/Application/SceneDelegate.swift @@ -46,6 +46,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { switch fetchResult { case .success(let refreshToken): + print("refreshToken: \(refreshToken)") reissueUseCase.execute(refreshToken: refreshToken) .observe(on: MainScheduler.instance) .subscribe( diff --git a/MLS/MLS/Resource/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/MLS/MLS/Resource/Assets.xcassets/AppIcon.appiconset/AppIcon.png index 7b4bfce1..89a25781 100644 Binary files a/MLS/MLS/Resource/Assets.xcassets/AppIcon.appiconset/AppIcon.png and b/MLS/MLS/Resource/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginReactor.swift b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginReactor.swift index 5aa15ac3..7bf0b45d 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginReactor.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginReactor.swift @@ -9,6 +9,7 @@ public final class LoginReactor: Reactor { case none case termsAgreements(credential: Credential, platform: LoginPlatform) case error + case home case dismiss } @@ -18,6 +19,7 @@ public final class LoginReactor: Reactor { case kakaoLoginButtonTapped case appleLoginButtonTapped case guestLoginButtonTapped + case backButtonTapped } public enum Mutation { @@ -72,6 +74,8 @@ public final class LoginReactor: Reactor { case .appleLoginButtonTapped: return handleAppleLogin() case .guestLoginButtonTapped: + return .just(.navigateTo(route: .home)) + case .backButtonTapped: return .just(.navigateTo(route: .dismiss)) } } @@ -102,8 +106,8 @@ private extension LoginReactor { .flatMap { response -> Observable in if response.isRegister { // 3. 회원가입된 유저면 FCM 토큰 등록 후 홈으로 이동 - return owner.putFCMTokenUseCase.execute(credential: response.accessToken, fcmToken: fcmToken) - .andThen(.just(.navigateTo(route: .dismiss))) + return owner.putFCMTokenUseCase.execute(fcmToken: fcmToken) + .andThen(.just(.navigateTo(route: .home))) } else { // 4. 미가입 유저면 약관 동의 화면으로 이동 return .just(.navigateTo(route: .termsAgreements( @@ -139,8 +143,8 @@ private extension LoginReactor { .flatMap { response -> Observable in if response.isRegister { // 3. 회원가입된 유저면 FCM 토큰 등록 후 홈으로 이동 - return owner.putFCMTokenUseCase.execute(credential: response.accessToken, fcmToken: fcmToken) - .andThen(.just(.navigateTo(route: .dismiss))) + return owner.putFCMTokenUseCase.execute(fcmToken: fcmToken) + .andThen(.just(.navigateTo(route: .home))) } else { // 4. 미가입 유저면 약관 동의 화면으로 이동 return .just(.navigateTo(route: .termsAgreements( diff --git a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift index 51437863..b6f1aad4 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginView.swift @@ -18,9 +18,14 @@ final class LoginView: UIView { static let buttonCenterXInset: CGFloat = buttonLogoImageLeadingInset + buttonLogoImageSize static let labelHeight: CGFloat = 28 static let subTitleBottomSpacing: CGFloat = -25 + static let recentLogoWidth: CGFloat = 82 + static let recentLogoHeight: CGFloat = 30 + static let recentLogoInset: CGFloat = 8 } // MARK: - Properties + public let header = NavigationBar(type: .arrowLeft) + private let loginImageView: UIImageView = { let image = DesignSystemAsset.image(named: "Login_KV_img") let view = UIImageView(image: image) @@ -91,6 +96,12 @@ final class LoginView: UIView { return label }() + private let recentLoginImageView: UIImageView = { + let view = UIImageView(image: DesignSystemAsset.image(named: "recentLoginLogo")) + view.isHidden = true + return view + }() + // MARK: - init init() { super.init(frame: .zero) @@ -111,6 +122,7 @@ private extension LoginView { func addViews() { addSubview(loginImageView) addSubview(buttonStackView) + addSubview(recentLoginImageView) buttonStackView.addArrangedSubview(kakaoLoginButton) buttonStackView.addArrangedSubview(appleLoginButton) @@ -119,9 +131,15 @@ private extension LoginView { kakaoLoginButton.addSubview(kakaoLoginLabel) appleLoginButton.addSubview(appleLogoImageView) appleLoginButton.addSubview(appleLoginLabel) + + addSubview(header) } func setupConstraints() { + header.snp.makeConstraints { make in + make.top.horizontalEdges.equalTo(safeAreaLayoutGuide) + } + loginImageView.snp.makeConstraints { make in make.width.equalToSuperview() make.height.equalTo(UIScreen.main.bounds.width * 1.49) @@ -189,12 +207,29 @@ extension LoginView { make.centerX.equalToSuperview() make.height.equalTo(Constant.labelHeight) } + + recentLoginImageView.isHidden = false + + recentLoginImageView.snp.remakeConstraints { make in + switch loginPlatform { + case .apple: + make.leading.equalTo(appleLoginButton).offset(Constant.recentLogoInset) + make.bottom.equalTo(appleLoginButton.snp.top).offset(Constant.recentLogoInset) + case .kakao: + make.leading.equalTo(kakaoLoginButton).offset(Constant.recentLogoInset) + make.bottom.equalTo(kakaoLoginButton.snp.top).offset(Constant.recentLogoInset) + default: + break + } + make.width.equalTo(Constant.recentLogoWidth) + make.height.equalTo(Constant.recentLogoHeight) + } case nil: buttonStackView.addArrangedSubview(guestLoginButton) guestLoginButton.snp.remakeConstraints { make in make.height.equalTo(Constant.buttonHeight) } - + recentLoginImageView.isHidden = true } setNeedsLayout() diff --git a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginViewController.swift b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginViewController.swift index 5a1ba53e..0fc4c2ba 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginViewController.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/Login/LoginViewController.swift @@ -58,6 +58,13 @@ private extension LoginViewController { func configureUI() { view.backgroundColor = .systemBackground + + if let navigationController = navigationController, + navigationController.viewControllers.count > 1 { + mainView.header.leftButton.isHidden = false + } else { + mainView.header.leftButton.isHidden = true + } } } @@ -115,6 +122,11 @@ public extension LoginViewController { .map { Reactor.Action.guestLoginButtonTapped } .bind(to: reactor.action) .disposed(by: disposeBag) + + mainView.header.leftButton.rx.tap + .map { Reactor.Action.backButtonTapped } + .bind(to: reactor.action) + .disposed(by: disposeBag) } func bindViewState(reactor: Reactor) { @@ -138,13 +150,15 @@ public extension LoginViewController { case .termsAgreements(let credential, let platform): let controller = owner.termsAgreementsFactory.make(credential: credential, platform: platform) owner.navigationController?.pushViewController(controller, animated: true) - case .dismiss: + case .home: owner.routeToHome.accept(()) case .error: DispatchQueue.main.async { let controller = BaseErrorViewController() owner.present(controller, animated: true) } + case .dismiss: + owner.navigationController?.popViewController(animated: true) default: break } diff --git a/MLS/Presentation/AuthFeature/AuthFeature/OnBoadingNotification/OnBoardingNotificationFactoryImpl.swift b/MLS/Presentation/AuthFeature/AuthFeature/OnBoadingNotification/OnBoardingNotificationFactoryImpl.swift index dcba72ae..dbccc528 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/OnBoadingNotification/OnBoardingNotificationFactoryImpl.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/OnBoadingNotification/OnBoardingNotificationFactoryImpl.swift @@ -1,6 +1,5 @@ import AuthFeatureInterface import BaseFeature -import DictionaryFeatureInterface public struct OnBoardingNotificationFactoryImpl: OnBoardingNotificationFactory { private let onBoardingNotificationSheetFactory: OnBoardingNotificationSheetFactory diff --git a/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementFactoryImpl.swift b/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementFactoryImpl.swift index ef9763b0..e069897f 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementFactoryImpl.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementFactoryImpl.swift @@ -7,7 +7,6 @@ public struct TermsAgreementFactoryImpl: TermsAgreementFactory { private let signUpWithKakaoUseCase: SignUpWithKakaoUseCase private let signUpWithAppleUseCase: SignUpWithAppleUseCase - private let saveTokenUseCase: SaveTokenToLocalUseCase private let fetchTokenUseCase: FetchTokenFromLocalUseCase private let updateMarketingAgreementUseCase: UpdateMarketingAgreementUseCase @@ -15,14 +14,12 @@ public struct TermsAgreementFactoryImpl: TermsAgreementFactory { onBoardingQuestionFactory: OnBoardingQuestionFactory, signUpWithKakaoUseCase: SignUpWithKakaoUseCase, signUpWithAppleUseCase: SignUpWithAppleUseCase, - saveTokenUseCase: SaveTokenToLocalUseCase, fetchTokenUseCase: FetchTokenFromLocalUseCase, updateMarketingAgreementUseCase: UpdateMarketingAgreementUseCase ) { self.onBoardingQuestionFactory = onBoardingQuestionFactory self.signUpWithKakaoUseCase = signUpWithKakaoUseCase self.signUpWithAppleUseCase = signUpWithAppleUseCase - self.saveTokenUseCase = saveTokenUseCase self.fetchTokenUseCase = fetchTokenUseCase self.updateMarketingAgreementUseCase = updateMarketingAgreementUseCase } @@ -35,7 +32,6 @@ public struct TermsAgreementFactoryImpl: TermsAgreementFactory { socialPlatform: platform, signUpWithKakaoUseCase: signUpWithKakaoUseCase, signUpWithAppleUseCase: signUpWithAppleUseCase, - saveTokenUseCase: saveTokenUseCase, fetchTokenUseCase: fetchTokenUseCase, updateMarketingAgreementUseCase: updateMarketingAgreementUseCase ) return viewController diff --git a/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementReactor.swift b/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementReactor.swift index 5674bddf..3aeb36ce 100644 --- a/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementReactor.swift +++ b/MLS/Presentation/AuthFeature/AuthFeature/TermsAgreement/TermsAgreementReactor.swift @@ -52,7 +52,6 @@ public final class TermsAgreementReactor: Reactor { private let socialPlatform: LoginPlatform private let signUpWithKakaoUseCase: SignUpWithKakaoUseCase private let signUpWithAppleUseCase: SignUpWithAppleUseCase - private let saveTokenUseCase: SaveTokenToLocalUseCase private let fetchTokenUseCase: FetchTokenFromLocalUseCase private let updateMarketingAgreementUseCase: UpdateMarketingAgreementUseCase @@ -62,7 +61,6 @@ public final class TermsAgreementReactor: Reactor { socialPlatform: LoginPlatform, signUpWithKakaoUseCase: SignUpWithKakaoUseCase, signUpWithAppleUseCase: SignUpWithAppleUseCase, - saveTokenUseCase: SaveTokenToLocalUseCase, fetchTokenUseCase: FetchTokenFromLocalUseCase, updateMarketingAgreementUseCase: UpdateMarketingAgreementUseCase ) { @@ -70,7 +68,6 @@ public final class TermsAgreementReactor: Reactor { self.socialPlatform = socialPlatform self.signUpWithKakaoUseCase = signUpWithKakaoUseCase self.signUpWithAppleUseCase = signUpWithAppleUseCase - self.saveTokenUseCase = saveTokenUseCase self.fetchTokenUseCase = fetchTokenUseCase self.updateMarketingAgreementUseCase = updateMarketingAgreementUseCase self.initialState = State() @@ -97,40 +94,25 @@ public final class TermsAgreementReactor: Reactor { } return .just(.setAgreeState(type: type, isOn: isOn)) case .bottomButtonTapped: - var fcmToken: String? - - UNUserNotificationCenter.current().getNotificationSettings { [weak self] settings in - guard let self else { return } - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - if case .success(let token) = fetchTokenUseCase.execute(type: .fcmToken) { - fcmToken = token - } - default: - fcmToken = nil + let fcmToken: String? = { + if case .success(let token) = fetchTokenUseCase.execute(type: .fcmToken) { + return token + } else { + return nil } - } + }() switch socialPlatform { case .kakao: - return signUpWithKakaoUseCase.execute(credential: credential, isMarketingAgreement: currentState.isMarketingAgree, fcmToken: fcmToken) - .withUnretained(self) - .map { owner, response in - let accessTokenResult = owner.saveTokenUseCase.execute(type: .accessToken, value: response.accessToken) - let refreshTokenResult = owner.saveTokenUseCase.execute(type: .refreshToken, value: response.refreshToken) - let isTokenSaveSuccess = owner.isTokenSaveSuccess(access: accessTokenResult, refresh: refreshTokenResult) - return isTokenSaveSuccess ? .navigateTo(route: .onBoarding) : .navigateTo(route: .error) - } + return signUpWithKakaoUseCase + .execute(credential: credential, isMarketingAgreement: currentState.isMarketingAgree, fcmToken: fcmToken) + .map { _ in .navigateTo(route: .onBoarding) } .catchAndReturn(.navigateTo(route: .error)) + case .apple: - return signUpWithAppleUseCase.execute(credential: credential, isMarketingAgreement: currentState.isMarketingAgree, fcmToken: fcmToken) - .withUnretained(self) - .map { owner, response in - let accessTokenResult = owner.saveTokenUseCase.execute(type: .accessToken, value: response.accessToken) - let refreshTokenResult = owner.saveTokenUseCase.execute(type: .refreshToken, value: response.refreshToken) - let isTokenSaveSuccess = owner.isTokenSaveSuccess(access: accessTokenResult, refresh: refreshTokenResult) - return isTokenSaveSuccess ? .navigateTo(route: .onBoarding) : .navigateTo(route: .error) - } + return signUpWithAppleUseCase + .execute(credential: credential, isMarketingAgreement: currentState.isMarketingAgree, fcmToken: fcmToken) + .map { _ in .navigateTo(route: .onBoarding) } .catchAndReturn(.navigateTo(route: .error)) } @@ -170,11 +152,4 @@ public final class TermsAgreementReactor: Reactor { return newState } - - private func isTokenSaveSuccess(access: Result, refresh: Result) -> Bool { - if case .success = access, case .success = refresh { - return true - } - return false - } } diff --git a/MLS/Presentation/AuthFeature/AuthFeatureDemo/SceneDelegate.swift b/MLS/Presentation/AuthFeature/AuthFeatureDemo/SceneDelegate.swift index 228440bb..e4fcd4d6 100644 --- a/MLS/Presentation/AuthFeature/AuthFeatureDemo/SceneDelegate.swift +++ b/MLS/Presentation/AuthFeature/AuthFeatureDemo/SceneDelegate.swift @@ -12,27 +12,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = UIWindow(windowScene: windowScene) window?.rootViewController = UINavigationController(rootViewController: ViewController()) window?.makeKeyAndVisible() - -// checkNotificationPermission() } -// -// private func checkNotificationPermission() { -// UNUserNotificationCenter.current().getNotificationSettings { settings in -// switch settings.authorizationStatus { -// case .notDetermined: -// UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { _, error in -// if let error = error { -// print(error) -// return -// } -// } -// case .denied, .authorized, .provisional, .ephemeral: -// print(settings.authorizationStatus) -// @unknown default: -// break -// } -// } -// } func sceneDidDisconnect(_ scene: UIScene) {} diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/WebViewController.swift b/MLS/Presentation/BaseFeature/BaseFeature/Shared/WebViewController.swift similarity index 72% rename from MLS/Presentation/MyPageFeature/MyPageFeature/WebViewController.swift rename to MLS/Presentation/BaseFeature/BaseFeature/Shared/WebViewController.swift index fbfd97f1..6a9dfd4c 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/WebViewController.swift +++ b/MLS/Presentation/BaseFeature/BaseFeature/Shared/WebViewController.swift @@ -1,11 +1,11 @@ import UIKit import WebKit -class WebViewController: UIViewController { +public final class WebViewController: UIViewController { private let urlString: String private let webView = WKWebView() - init(urlString: String) { + public init(urlString: String) { self.urlString = urlString super.init(nibName: nil, bundle: nil) } @@ -14,11 +14,11 @@ class WebViewController: UIViewController { fatalError("init(coder:) has not been implemented") } - override func loadView() { + public override func loadView() { self.view = webView } - override func viewDidLoad() { + public override func viewDidLoad() { super.viewDidLoad() if let url = URL(string: urlString) { webView.load(URLRequest(url: url)) diff --git a/MLS/Presentation/BaseFeature/BaseFeature/UICollectionReusableViews/CollectionViewCells/DictionaryListCell.swift b/MLS/Presentation/BaseFeature/BaseFeature/UICollectionReusableViews/CollectionViewCells/DictionaryListCell.swift index 1909dfd9..33361c55 100644 --- a/MLS/Presentation/BaseFeature/BaseFeature/UICollectionReusableViews/CollectionViewCells/DictionaryListCell.swift +++ b/MLS/Presentation/BaseFeature/BaseFeature/UICollectionReusableViews/CollectionViewCells/DictionaryListCell.swift @@ -5,7 +5,7 @@ import DomainInterface public final class DictionaryListCell: UICollectionViewCell { // MARK: - Properties - private var onBookmarkTapped: ((Bool) -> Void)? + private var onBookmarkTapped: (() -> Void)? // MARK: - Components public let cellView = CardList() @@ -70,7 +70,7 @@ public extension DictionaryListCell { indexPath: IndexPath, collectionView: UICollectionView, isMap: Bool = false, - onBookmarkTapped: @escaping (Bool) -> Void + onBookmarkTapped: @escaping () -> Void ) { cellView.setType(type: type) cellView.setImage(image: UIImage(), backgroundColor: input.type.backgroundColor) // 초기화 @@ -94,8 +94,8 @@ public extension DictionaryListCell { cellView.setSubText(text: input.subText) cellView.setSelected(isSelected: input.isBookmarked) self.onBookmarkTapped = onBookmarkTapped - cellView.onIconTapped = { [weak self] isSelected in - self?.onBookmarkTapped?(isSelected) + cellView.onIconTapped = { [weak self] in + self?.onBookmarkTapped?() } } func updateBookmarkState(isBookmarked: Bool) { diff --git a/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift b/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift index d27b345e..12d07a00 100644 --- a/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift +++ b/MLS/Presentation/BaseFeature/BaseFeature/Utills/Extension/String+.swift @@ -1,7 +1,25 @@ +import Foundation + extension String { public func isOnlyKorean() -> Bool { - let initialConsonants = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ" - return self.allSatisfy { initialConsonants.contains($0) } + return !self.isEmpty && self.allSatisfy { char in + guard let scalar = char.unicodeScalars.first else { return false } + return !(0xAC00...0xD7A3).contains(scalar.value) + } } + public func toDisplayDateString() -> String { + let inputFormatter = DateFormatter() + inputFormatter.locale = Locale(identifier: "ko_KR") + inputFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") + inputFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + + guard let date = inputFormatter.date(from: self) else { return self } + + let outputFormatter = DateFormatter() + outputFormatter.locale = Locale(identifier: "ko_KR") + outputFormatter.dateFormat = "yyyy.MM.dd HH:mm" + + return outputFormatter.string(from: date) + } } diff --git a/MLS/Presentation/BaseFeature/BaseFeature/Utills/NotificationPermissionManager.swift b/MLS/Presentation/BaseFeature/BaseFeature/Utills/NotificationPermissionManager.swift index 6665635e..8f41e7cd 100644 --- a/MLS/Presentation/BaseFeature/BaseFeature/Utills/NotificationPermissionManager.swift +++ b/MLS/Presentation/BaseFeature/BaseFeature/Utills/NotificationPermissionManager.swift @@ -12,7 +12,6 @@ public final class NotificationPermissionManager { } } - @discardableResult public func requestIfNeeded( application: UIApplication = .shared, completion: ((Bool) -> Void)? = nil @@ -49,7 +48,7 @@ public final class NotificationPermissionManager { print("🚫 알림 권한 거부 상태입니다. 설정에서 변경해야 함") completion?(false) - @unknown default: + default: completion?(false) } } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionFactoryImpl.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionFactoryImpl.swift index d261c38b..dd6e1a8f 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionFactoryImpl.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionFactoryImpl.swift @@ -4,9 +4,9 @@ import DomainInterface public final class AddCollectionFactoryImpl: AddCollectionFactory { private let createCollectionListUseCase: CreateCollectionListUseCase - private let setCollectionUseCase: SetCollectionUseCase + private let setCollectionUseCase: UpdateCollectionUseCase - public init(createCollectionListUseCase: CreateCollectionListUseCase, setCollectionUseCase: SetCollectionUseCase) { + public init(createCollectionListUseCase: CreateCollectionListUseCase, setCollectionUseCase: UpdateCollectionUseCase) { self.createCollectionListUseCase = createCollectionListUseCase self.setCollectionUseCase = setCollectionUseCase } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionReactor.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionReactor.swift index 4f2cd581..2a3c4952 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionReactor.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionReactor.swift @@ -8,6 +8,8 @@ public final class AddCollectionModalReactor: Reactor { public enum Route { case dismiss case dismissWithData + case createError + case updateError } // MARK: - Action @@ -38,10 +40,10 @@ public final class AddCollectionModalReactor: Reactor { public var initialState = State(collection: nil) private let createCollectionListUseCase: CreateCollectionListUseCase - private let setCollectionUseCase: SetCollectionUseCase + private let setCollectionUseCase: UpdateCollectionUseCase // MARK: - Init - public init(collection: CollectionResponse?, createCollectionListUseCase: CreateCollectionListUseCase, setCollectionUseCase: SetCollectionUseCase) { + public init(collection: CollectionResponse?, createCollectionListUseCase: CreateCollectionListUseCase, setCollectionUseCase: UpdateCollectionUseCase) { self.initialState = State(collection: collection, inputText: collection?.name) self.createCollectionListUseCase = createCollectionListUseCase self.setCollectionUseCase = setCollectionUseCase @@ -71,6 +73,9 @@ public final class AddCollectionModalReactor: Reactor { if currentState.collection == nil { return createCollectionListUseCase.execute(name: trimmed) .andThen(.just(.toNavigate(.dismissWithData))) + .catch { _ in + return .just(.toNavigate(.createError)) + } } else { guard let id = currentState.collection?.collectionId else { return .empty() } return setCollectionUseCase.execute( @@ -78,6 +83,9 @@ public final class AddCollectionModalReactor: Reactor { name: trimmed ) .andThen(.just(.toNavigate(.dismissWithData))) + .catch { _ in + return .just(.toNavigate(.updateError)) + } } } } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionViewController.swift index 91f3f2e3..fd32d227 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/AddCollection/AddCollectionViewController.swift @@ -183,6 +183,10 @@ extension AddCollectionViewController { owner.dismissWithAnimation(withData: true) { owner.dismiss(animated: false) } + case .updateError: + ToastFactory.createToast(message: "컬렉션 수정에 실패했어요. 다시 시도해주세요.") + case .createError: + ToastFactory.createToast(message: "컬렉션 생성에 실패했어요. 다시 시도해주세요.") default: break } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListReactor.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListReactor.swift index de507057..38e19e26 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListReactor.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListReactor.swift @@ -13,6 +13,15 @@ public final class BookmarkListReactor: Reactor { case dictionary case login case edit + case bookmarkError + } + + public enum UIEvent { + case none + case add(BookmarkResponse) + case delete(BookmarkResponse) + case undo + case login } enum ViewState: Equatable { @@ -24,7 +33,7 @@ public final class BookmarkListReactor: Reactor { // MARK: - Action public enum Action { case viewWillAppear - case toggleBookmark(Int, Bool) + case toggleBookmark(Int) case sortButtonTapped case filterButtonTapped case editButtonTapped @@ -35,6 +44,7 @@ public final class BookmarkListReactor: Reactor { case dataTapped(Int) case emptyButtonTapped case itemFilterOptionSelected([(String, String)]) + case showLogin } // MARK: - Mutation @@ -44,13 +54,15 @@ public final class BookmarkListReactor: Reactor { case setSort(SortType) case setFilter(start: Int?, end: Int?) case setLastDeletedBookmark(BookmarkResponse?) - case toNavagate(Route) + case navigatTo(Route) case setJobId([Int]) case setCategoryId([Int]) + case setEvent(UIEvent) } // MARK: - State public struct State { + @Pulse var uiEvent: UIEvent = .none @Pulse var route: Route var items: [BookmarkResponse] = [] var type: DictionaryType @@ -130,27 +142,14 @@ public final class BookmarkListReactor: Reactor { } } - case let .toggleBookmark(id, isSelected): - guard let bookmarkItem = currentState.items.first(where: { $0.originalId == id }) else { return .empty() } - - let saveDeletedMutation: Observable = - isSelected ? .just(.setLastDeletedBookmark(bookmarkItem)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeletedMutation - .concat( - setBookmarkUseCase.execute( - bookmarkId: isSelected ? bookmarkItem.bookmarkId : id, - isBookmark: isSelected ? .delete : .set(bookmarkItem.type) - ) - .andThen(fetchList()) - ) + case let .toggleBookmark(id): + return handleTogle(id: id) case .sortButtonTapped: - return .just(.toNavagate(.sort(currentState.type))) + return .just(.navigatTo(.sort(currentState.type))) case .filterButtonTapped: - return .just(.toNavagate(.filter(currentState.type))) + return .just(.navigatTo(.filter(currentState.type))) case .fetchList: guard currentState.isLogin else { return .empty() } @@ -159,7 +158,7 @@ public final class BookmarkListReactor: Reactor { case let .sortOptionSelected(sort): return Observable.concat([ .just(.setSort(sort)), - fetchList() + fetchList(sort: sort) ]) case let .filterOptionSelected(startLevel, endLevel): @@ -169,57 +168,50 @@ public final class BookmarkListReactor: Reactor { ]) case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } - return setBookmarkUseCase.execute( - bookmarkId: lastDeleted.originalId, - isBookmark: .set(lastDeleted.type) - ) - .andThen( - Observable.concat([ - fetchList(), - .just(.setLastDeletedBookmark(nil)) - ]) - ) - case .dataTapped(let index): + return handleUndo() + + case let .dataTapped(index): let item = currentState.items[index] guard let type = item.type.toDictionaryType else { return .empty() } - return .just(.toNavagate(.detail(type, item.originalId))) + return .just(.navigatTo(.detail(type, item.originalId))) case .emptyButtonTapped: if currentState.viewState == .logout { - return .just(.toNavagate(.login)) + return .just(.navigatTo(.login)) } else { - return .just(.toNavagate(.dictionary)) + return .just(.navigatTo(.dictionary)) } case .editButtonTapped: - return .just(.toNavagate(.edit)) - case .itemFilterOptionSelected(let results): + return .just(.navigatTo(.edit)) + case let .itemFilterOptionSelected(results): let criteria = parseItemFilterResultUseCase.execute(results: results) - return .concat([ - .just(.setJobId(criteria.jobIds)), - .just(.setFilter(start: criteria.startLevel, end: criteria.endLevel)), - .just(.setCategoryId(criteria.categoryIds)) - ]) - .concat(Observable.deferred { [weak self] in - guard let self = self else { return .empty() } - return self.fetchList() - }) + return .concat([ + .just(.setJobId(criteria.jobIds)), + .just(.setFilter(start: criteria.startLevel, end: criteria.endLevel)), + .just(.setCategoryId(criteria.categoryIds)) + ]) + .concat(Observable.deferred { [weak self] in + guard let self = self else { return .empty() } + return self.fetchList() + }) + case .showLogin: + return .just(.setEvent(.login)) } } // MARK: - Fetch List - private func fetchList() -> Observable { + private func fetchList(sort: SortType? = nil) -> Observable { switch currentState.type { case .total: return fetchTotalBookmarkUseCase.execute( - sort: currentState.sort + sort: sort ?? currentState.sort ).map { .setItems($0) } case .monster: return fetchMonsterBookmarkUseCase.execute( minLevel: currentState.startLevel ?? 1, maxLevel: currentState.endLevel ?? 200, - sort: currentState.sort + sort: sort ?? currentState.sort ).map { .setItems($0) } case .item: @@ -228,19 +220,19 @@ public final class BookmarkListReactor: Reactor { minLevel: currentState.startLevel, maxLevel: currentState.endLevel, categoryIds: nil, - sort: currentState.sort + sort: sort ?? currentState.sort ).map { .setItems($0) } case .npc: - return fetchNPCBookmarkUseCase.execute(sort: currentState.sort) + return fetchNPCBookmarkUseCase.execute(sort: sort ?? currentState.sort) .map { .setItems($0) } case .quest: - return fetchQuestBookmarkUseCase.execute(sort: currentState.sort) + return fetchQuestBookmarkUseCase.execute(sort: sort ?? currentState.sort) .map { .setItems($0) } case .map: - return fetchMapBookmarkUseCase.execute(sort: currentState.sort) + return fetchMapBookmarkUseCase.execute(sort: sort ?? currentState.sort) .map { .setItems($0) } default: @@ -264,14 +256,64 @@ public final class BookmarkListReactor: Reactor { newState.endLevel = end case let .setLastDeletedBookmark(item): newState.lastDeletedBookmark = item - case .toNavagate(let route): + case let .navigatTo(route): newState.route = route - case .setJobId(let ids): + case let .setJobId(ids): newState.jobId = ids - case .setCategoryId(let ids): + case let .setCategoryId(ids): newState.categoryIds = ids + case let .setEvent(event): + newState.uiEvent = event } return newState } } + +private extension BookmarkListReactor { + func handleTogle(id: Int) -> Observable { + guard let index = currentState.items.firstIndex(where: { $0.originalId == id }) else { + return .empty() + } + + let targetItem = currentState.items[index] + + return setBookmarkUseCase.execute(bookmarkId: targetItem.bookmarkId, isBookmark: .delete) + .flatMap { _ -> Observable in + let lastItem = Mutation.setLastDeletedBookmark(targetItem) + + let eventMutation = Mutation.setEvent(.delete(targetItem)) + + return Observable.concat([ + .from([lastItem, eventMutation]), + self.fetchList() + ]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndo() -> Observable { + guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: lastDeleted.originalId, + isBookmark: .set(lastDeleted.type) + ) + .flatMap { _ -> Observable in + let lastItem = Mutation.setLastDeletedBookmark(nil) + + let event: UIEvent = .add(lastDeleted) + let eventMutation = Mutation.setEvent(event) + + return Observable.concat([ + .from([lastItem, eventMutation]), + self.fetchList() + ]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListViewController.swift index d0ec0e6c..5f7e9777 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkList/BookmarkListViewController.swift @@ -5,6 +5,7 @@ import BaseFeature import BookmarkFeatureInterface import DesignSystem import DictionaryFeatureInterface +import DomainInterface import ReactorKit import RxCocoa @@ -16,6 +17,10 @@ public final class BookmarkListViewController: BaseViewController, View { // MARK: - Properties public var disposeBag = DisposeBag() + private let bookmarkChangeRelay = PublishRelay<(id: Int, newBookmarkId: Int?)>() + private var undoRelay = PublishRelay() + private var addCollectionRelay = PublishRelay() + private let itemFilterFactory: ItemFilterBottomSheetFactory private let monsterFilterFactory: MonsterFilterBottomSheetFactory private let bookmarkModalFactory: BookmarkModalFactory @@ -147,6 +152,7 @@ extension BookmarkListViewController { .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { case .sort(let type): @@ -180,7 +186,7 @@ extension BookmarkListViewController { break } case .detail(let type, let id): - let viewcontroller = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: nil) + let viewcontroller = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkChangeRelay, loginRelay: nil) owner.navigationController?.pushViewController(viewcontroller, animated: true) case .login: let viewcontroller = owner.loginFactory.make(exitRoute: .pop) @@ -194,6 +200,8 @@ extension BookmarkListViewController { case .edit: let viewController = owner.collectionEditFactory.make(bookmarks: reactor.currentState.items) owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") default: break } @@ -209,7 +217,90 @@ extension BookmarkListViewController { owner.mainView.updateFilter(sortType: type.bookmarkSortedFilter.first) }) .disposed(by: disposeBag) + + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$uiEvent) } + .withUnretained(self) + .subscribe(onNext: { owner, event in + switch event { + case .add(let item): + owner.presentAddSnackBar(item: item) + case .delete(let item): + owner.presentDeleteSnackBar(item: item) + case .login: + owner.presentLoginGuide() + default: + break + } + }) + .disposed(by: disposeBag) } + + private func presentAddSnackBar(item: BookmarkResponse) { + SnackBarFactory.createSnackBar( + type: .normal, + imageUrl: item.imageUrl, + imageBackgroundColor: item.type.backgroundColor, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: { [weak self] in + self?.reactor?.state.map(\.items) + .compactMap { items in + items.first(where: { $0.originalId == item.originalId })?.bookmarkId + } + .take(1) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] bookmarkId in + guard let self else { return } + let vc = self.bookmarkModalFactory.make(bookmarkIds: [bookmarkId]) { isAdd in + if isAdd { + ToastFactory.createToast( + message: "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요." + ) + } + } + vc.modalPresentationStyle = .pageSheet + if let sheet = vc.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 16 + } + self.present(vc, animated: true) + }) + .disposed(by: self?.disposeBag ?? DisposeBag()) + } + ) + } + + private func presentDeleteSnackBar(item: BookmarkResponse) { + SnackBarFactory.createSnackBar( + type: .delete, + imageUrl: item.imageUrl, + imageBackgroundColor: item.type.backgroundColor, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: { [weak self] in + self?.undoRelay.accept(()) + self?.reactor?.action.onNext(.undoLastDeletedBookmark) + } + ) + } + + private func presentLoginGuide() { + GuideAlertFactory.show( + mainText: "북마크를 하려면 로그인이 필요해요.", + ctaText: "로그인 하기", + cancelText: "취소", + ctaAction: { [weak self] in + guard let self else { return } + let vc = self.loginFactory.make(exitRoute: .pop) + self.navigationController?.pushViewController(vc, animated: true) + }, + cancelAction: nil + ) + } + } // MARK: - Delegate @@ -245,36 +336,14 @@ extension BookmarkListViewController: UICollectionViewDelegate, UICollectionView indexPath: indexPath, collectionView: collectionView, isMap: item.type == .map, - onBookmarkTapped: { [weak self] isSelected in - guard let self = self else { return } - // 로그인 상태 확인 + onBookmarkTapped: { [weak self] in + guard let self else { return } guard state.isLogin else { - GuideAlertFactory.show( - mainText: "북마크를 하려면 로그인이 필요해요.", - ctaText: "로그인 하기", - cancelText: "취소", - ctaAction: { - let viewController = self.loginFactory.make(exitRoute: .pop) - self.navigationController?.pushViewController(viewController, animated: true) - }, - cancelAction: nil - ) + self.reactor?.action.onNext(.showLogin) return } - - self.reactor?.action.onNext(.toggleBookmark(item.originalId, isSelected)) - - SnackBarFactory.createSnackBar( - type: .delete, - imageUrl: item.imageUrl, - imageBackgroundColor: item.type.backgroundColor, - text: "아이템을 북마크에서 삭제했어요.", - buttonText: "되돌리기", - buttonAction: { [weak self] in - self?.reactor?.action.onNext(.undoLastDeletedBookmark) - } - ) + self.reactor?.action.onNext(.toggleBookmark(item.originalId)) } ) diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkMain/BookmarkMainViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkMain/BookmarkMainViewController.swift index 8c36fc05..d1fd9e50 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkMain/BookmarkMainViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkMain/BookmarkMainViewController.swift @@ -187,7 +187,7 @@ public extension BookmarkMainViewController { .observe(on: MainScheduler.instance) .bind { owner, isLogin in owner.mainView.updateLoginState(isLogin: isLogin) - owner.underLineController.setHidden(hidden: true) + owner.underLineController.setHidden(hidden: !isLogin) } .disposed(by: disposeBag) diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalReactor.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalReactor.swift index 121ff92b..88011427 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalReactor.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalReactor.swift @@ -10,6 +10,7 @@ public final class BookmarkModalReactor: Reactor { case dismiss case dismissWithData case addCollection + case collectionError } public enum Action { @@ -22,7 +23,7 @@ public final class BookmarkModalReactor: Reactor { } public enum Mutation { - case toNavigate(Route) + case navigatTo(Route) case checkCollection([CollectionResponse]) case setCollection([CollectionResponse]) } @@ -59,13 +60,16 @@ public final class BookmarkModalReactor: Reactor { collectionIds: currentState.selectedItems.map { $0.collectionId }, bookmarkIds: currentState.bookmarkIds ) - .andThen(.just(.toNavigate(.dismissWithData))) + .andThen(.just(.navigatTo(.dismissWithData))) + .catch { _ in + .just(.navigatTo(.collectionError)) + } case .backButtonTapped: - return .just(.toNavigate(.dismiss)) + return .just(.navigatTo(.dismiss)) case .addCollectionTapped: - return .just(.toNavigate(.addCollection)) + return .just(.navigatTo(.addCollection)) case .selectItem(let id): var newItems = currentState.selectedItems @@ -81,7 +85,7 @@ public final class BookmarkModalReactor: Reactor { public func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { - case .toNavigate(let route): + case .navigatTo(let route): newState.route = route case .checkCollection(let collections): newState.selectedItems = collections diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalViewController.swift index 2711e246..ce1d4aa3 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/BookmarkModal/BookmarkModalViewController.swift @@ -138,6 +138,8 @@ extension BookmarkModalViewController { .disposed(by: owner.disposeBag) owner.present(viewController, animated: true) + case .collectionError: + ToastFactory.createToast(message: "컬렉션 저장에 실패했어요. 다시 시도해주세요.") default: break } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailReactor.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailReactor.swift index 1df67134..5dd270c7 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailReactor.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailReactor.swift @@ -18,10 +18,8 @@ public final class CollectionDetailReactor: Reactor { case editButtonTapped case addButtonTapped case bookmarkButtonTapped - case toggleBookmark(Int, Bool) case selectSetting(CollectionSettingMenu) case changeName(String) - case undoLastDeletedBookmark case dataTapped(Int) case deleteCollection } @@ -55,7 +53,13 @@ public final class CollectionDetailReactor: Reactor { private let disposeBag = DisposeBag() - public init(collection: CollectionResponse, setBookmarkUseCase: SetBookmarkUseCase, fetchCollectionUseCase: FetchCollectionUseCase, deleteCollectionUseCase: DeleteCollectionUseCase, addCollectionAndBookmarkUseCase: AddCollectionAndBookmarkUseCase) { + public init( + collection: CollectionResponse, + setBookmarkUseCase: SetBookmarkUseCase, + fetchCollectionUseCase: FetchCollectionUseCase, + deleteCollectionUseCase: DeleteCollectionUseCase, + addCollectionAndBookmarkUseCase: AddCollectionAndBookmarkUseCase + ) { self.initialState = State(route: .none, collection: collection) self.setBookmarkUseCase = setBookmarkUseCase self.fetchCollectionUseCase = fetchCollectionUseCase @@ -65,21 +69,6 @@ public final class CollectionDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { - case .toggleBookmark(let id, let isSelected): - guard let bookmarkItem = currentState.collection.recentBookmarks.first(where: { $0.originalId == id }) else { return .empty() } - - let saveDeletedMutation: Observable = - isSelected ? .just(.setLastDeletedBookmark(bookmarkItem)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeletedMutation - .concat( - setBookmarkUseCase.execute( - bookmarkId: isSelected ? bookmarkItem.bookmarkId : id, - isBookmark: isSelected ? .delete : .set(bookmarkItem.type) - ) - .andThen(fetchCollectionUseCase.execute(id: currentState.collection.collectionId).map { .setItems($0) }) - ) case .backButtonTapped: return .just(.navigateTo(.dismiss)) case .editButtonTapped: @@ -95,22 +84,6 @@ public final class CollectionDetailReactor: Reactor { return .just(.setMenu(menu)) case .changeName(let name): return .just(.setName(name)) - case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark, - let lastDeletedBookmarkId = currentState.lastDeletedBookmark?.bookmarkId else { return .empty() } - return setBookmarkUseCase.execute( - bookmarkId: lastDeleted.originalId, - isBookmark: .set(lastDeleted.type) - ) - // 북마크 다시 설정시 이전 collection을 전부 추적해야하고 새로 바뀐 북마크ID가 필요하여 현재는 원할하게 동작하지 않음 - .andThen(addCollectionAndBookmarkUseCase.execute(collectionIds: [currentState.collection.collectionId], bookmarkIds: [lastDeletedBookmarkId])) - .andThen( - Observable.concat([ - fetchCollectionUseCase.execute(id: currentState.collection.collectionId) - .map { .setItems($0) }, - .just(.setLastDeletedBookmark(nil)) - ]) - ) case .dataTapped(let index): let item = currentState.collection.recentBookmarks[index] guard let type = item.type.toDictionaryType else { return .empty() } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailViewController.swift index e7b7b81b..9c11aa03 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionDetail/CollectionDetailViewController.swift @@ -197,7 +197,7 @@ extension CollectionDetailViewController { }) owner.presentModal(viewController) case .detail(let type, let id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: nil) + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: nil, loginRelay: nil) owner.navigationController?.pushViewController(viewController, animated: true) default: break @@ -237,22 +237,7 @@ extension CollectionDetailViewController: UICollectionViewDelegate, UICollection ), indexPath: indexPath, collectionView: collectionView, - onBookmarkTapped: { [weak self] isSelected in - guard let self = self else { return } - - self.reactor?.action.onNext(.toggleBookmark(item.originalId, isSelected)) - - SnackBarFactory.createSnackBar( - type: .delete, - imageUrl: item.imageUrl, - imageBackgroundColor: item.type.backgroundColor, - text: "아이템을 북마크에서 삭제했어요.", - buttonText: "되돌리기", - buttonAction: { [weak self] in - self?.reactor?.action.onNext(.undoLastDeletedBookmark) - } - ) - } + onBookmarkTapped: { } ) return cell } diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditReactor.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditReactor.swift index 56e34e8e..c9616086 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditReactor.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditReactor.swift @@ -15,8 +15,6 @@ public final class CollectionEditReactor: Reactor { public enum Action { case backButtonTapped case addCollectionButtonTapped - case completeButtonTapped - case dismissAddCollection([CollectionResponse]) case itemTapped(Int) } @@ -48,12 +46,6 @@ public final class CollectionEditReactor: Reactor { return .just(.navigateTo(.dismiss)) case .addCollectionButtonTapped: return .just(.navigateTo(.collcectionList)) - case .completeButtonTapped: - // 선택된 북마크들을 선택된 컬렉션들에 저장 - return .empty() - case .dismissAddCollection(let collections): - // addCollection에서 선택된 컬렉션 목록 저장 - return .empty() case .itemTapped(let index): let item = currentState.bookmarks[index] var newItems = currentState.selectedItems diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditViewController.swift index add5f4c0..af476175 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionEdit/CollectionEditViewController.swift @@ -173,7 +173,7 @@ extension CollectionEditViewController: UICollectionViewDelegate, UICollectionVi ), indexPath: indexPath, collectionView: collectionView, - onBookmarkTapped: { [weak self] _ in + onBookmarkTapped: { [weak self] in self?.reactor?.action.onNext(.itemTapped(indexPath.row)) } ) diff --git a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionList/CollectionListViewController.swift b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionList/CollectionListViewController.swift index b4ea36e7..f1175a4b 100644 --- a/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionList/CollectionListViewController.swift +++ b/MLS/Presentation/BookmarkFeature/BookmarkFeature/CollectionList/CollectionListViewController.swift @@ -16,8 +16,6 @@ public final class CollectionListViewController: BaseViewController, View { public var disposeBag = DisposeBag() private var selectedSortIndex = 0 -// public var onDismissWithMessage: ((CollectionResponse?) -> Void)? - private let addCollectionFactory: AddCollectionFactory private let detailFactory: CollectionDetailFactory private let sortedBottomSheetFactory: SortedBottomSheetFactory @@ -120,6 +118,7 @@ extension CollectionListViewController { let viewController = owner.detailFactory.make(collection: collection, onMoveToMain: { if let tabBarController = owner.tabBarController as? BottomTabBarController { tabBarController.selectTab(index: 0) + DictionaryTabRegistry.changeTab(index: 0) } }) owner.tabBarController?.navigationController?.pushViewController(viewController, animated: true) diff --git a/MLS/Presentation/DesignSystem/DesignSystem/Components/CardList.swift b/MLS/Presentation/DesignSystem/DesignSystem/Components/CardList.swift index 7e5b42ec..312ac972 100644 --- a/MLS/Presentation/DesignSystem/DesignSystem/Components/CardList.swift +++ b/MLS/Presentation/DesignSystem/DesignSystem/Components/CardList.swift @@ -70,7 +70,7 @@ public final class CardList: UIView { } } - public var onIconTapped: ((Bool) -> Void)? + public var onIconTapped: (() -> Void)? // MARK: - Components public let imageView = ItemImageView(image: nil, cornerRadius: Constant.imageRadius, inset: Constant.imageInset, backgroundColor: .listMap) @@ -193,7 +193,7 @@ private extension CardList { func bindButton() { iconButton.addAction(UIAction(handler: { [weak self] _ in guard let self = self else { return } - self.onIconTapped?(self.isIconSelected) + self.onIconTapped?() }), for: .touchUpInside) } diff --git a/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift b/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift index adfb9941..1a89a6b0 100644 --- a/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift +++ b/MLS/Presentation/DesignSystem/DesignSystem/Components/Tabbar/BottomTabBarController.swift @@ -17,7 +17,7 @@ public final class BottomTabBarController: UITabBarController { public init(viewControllers: [UIViewController], initialIndex: Int = 0) { tabItems = [ TabItem(title: "도감", icon: .dictionary), - TabItem(title: "북마크", icon: .bookmark), + TabItem(title: "북마크", icon: .bookmarkList), TabItem(title: "MY", icon: .mypage) ] customTabBar = BottomTabBar(tabItems: tabItems, selectedIndex: initialIndex) diff --git a/MLS/Presentation/DesignSystem/DesignSystem/Resource/Image.xcassets/image/recentLoginLogo.imageset/Contents.json b/MLS/Presentation/DesignSystem/DesignSystem/Resource/Image.xcassets/image/recentLoginLogo.imageset/Contents.json new file mode 100644 index 00000000..c1fb5668 --- /dev/null +++ b/MLS/Presentation/DesignSystem/DesignSystem/Resource/Image.xcassets/image/recentLoginLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "recentLoginLogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/MLS/Presentation/DesignSystem/DesignSystem/Resource/Image.xcassets/image/recentLoginLogo.imageset/recentLoginLogo.png b/MLS/Presentation/DesignSystem/DesignSystem/Resource/Image.xcassets/image/recentLoginLogo.imageset/recentLoginLogo.png new file mode 100644 index 00000000..4afde34f Binary files /dev/null and b/MLS/Presentation/DesignSystem/DesignSystem/Resource/Image.xcassets/image/recentLoginLogo.imageset/recentLoginLogo.png differ diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseView.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseView.swift index 2336b3c8..eb3e4c90 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseView.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseView.swift @@ -482,6 +482,7 @@ extension DictionaryDetailBaseView { } func setBookmark(isBookmarked: Bool) { + bookmarkButton.isSelected = isBookmarked bookmarkButton.setImage(DesignSystemAsset.image(named: isBookmarked ? "bookmark" : "bookmarkGrayBorder"), for: .normal) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseViewController.swift index d37a776d..4245a583 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailBaseViewController.swift @@ -16,7 +16,8 @@ class DictionaryDetailBaseViewController: BaseViewController { private var didSelectInitialTab = false var selectedIndex = 0 - var bookmarkRelay: PublishRelay<(Int, Bool)>? + var bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>? + var loginRelay: PublishRelay? /// 각 탭에 해당하는 콘텐츠 뷰들을 담는 배열 public var contentViews: [UIView] = [] { @@ -30,8 +31,8 @@ class DictionaryDetailBaseViewController: BaseViewController { /// 현재 보여지고 있는 뷰의 인덱스 private var currentTabIndex: Int? - private let bookmarkModalFactory: BookmarkModalFactory - private let loginFactory: LoginFactory + let bookmarkModalFactory: BookmarkModalFactory + let loginFactory: LoginFactory public let dictionaryDetailFactory: DictionaryDetailFactory private let detailOnBoardingFactory: DetailOnBoardingFactory private let appCoordinator: AppCoordinatorProtocol @@ -52,7 +53,8 @@ class DictionaryDetailBaseViewController: BaseViewController { detailOnBoardingFactory: DetailOnBoardingFactory, appCoordinator: AppCoordinatorProtocol, fetchVisitDictionaryDetailUseCase: FetchVisitDictionaryDetailUseCase, - bookmarkRelay: PublishRelay<(Int, Bool)>? + bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, + loginRelay: PublishRelay? ) { self.type = type self.bookmarkModalFactory = bookmarkModalFactory @@ -63,6 +65,7 @@ class DictionaryDetailBaseViewController: BaseViewController { self.fetchVisitDictionaryDetailUseCase = fetchVisitDictionaryDetailUseCase mainView.titleLabel.attributedText = .makeStyledString(font: .sub_m_b, text: type.detailTitle) self.bookmarkRelay = bookmarkRelay + self.loginRelay = loginRelay super.init() } @@ -90,6 +93,18 @@ class DictionaryDetailBaseViewController: BaseViewController { didSelectInitialTab = true } } + + open func toggleBookmark() { + assertionFailure("Subclass should override toggleBookmark()") + } + + open func checkLogin() -> Bool? { + return nil + } + + open func undoBookmark() { + assertionFailure("Subclass should override undoBookmark()") + } } // MARK: - SetUp @@ -265,86 +280,6 @@ extension DictionaryDetailBaseViewController { currentTabIndex = index } - func bindBookmarkButton( - buttonTap: ControlEvent, - currentItem: Observable, - isLogin: @escaping () -> Bool, - id: @escaping (T) -> Int, - imageUrl: @escaping (T) -> String?, - backgroundColor: UIColor, - isBookmarked: @escaping (T) -> Bool, - toggleBookmark: @escaping (Bool) -> Void, - undoLastDeleted: @escaping () -> Void, - bookmarkId: Observable - ) -> Disposable { - buttonTap - .withLatestFrom(Observable.combineLatest(currentItem, bookmarkId)) - .observe(on: MainScheduler.instance) - .bind { [weak self] item, bookmarkId in - guard let self else { return } - guard isLogin() else { - GuideAlertFactory.show( - mainText: "북마크를 하려면 로그인이 필요해요.", - ctaText: "로그인 하기", - cancelText: "취소", - ctaAction: { - let viewController = self.loginFactory.make(exitRoute: .pop) - self.navigationController?.pushViewController(viewController, animated: true) - }, - cancelAction: nil - ) - return - } - - let itemId = id(item) - - if isBookmarked(item) { - toggleBookmark(true) - self.bookmarkRelay?.accept((itemId, false)) - SnackBarFactory.createSnackBar( - type: .delete, - imageUrl: imageUrl(item), - imageBackgroundColor: backgroundColor, - text: "아이템을 북마크에서 삭제했어요.", - buttonText: "되돌리기", - buttonAction: { undoLastDeleted() } - ) - } else { - toggleBookmark(false) - self.bookmarkRelay?.accept((itemId, true)) - SnackBarFactory.createSnackBar( - type: .normal, - imageUrl: imageUrl(item), - imageBackgroundColor: backgroundColor, - text: "아이템을 북마크에 추가했어요.", - buttonText: "컬렉션 추가", - buttonAction: { [weak self] in - guard let self, - let id = bookmarkId else { return } - let viewController = self.bookmarkModalFactory.make(bookmarkIds: [id], onComplete: { isAdd in - if isAdd { - DispatchQueue.main.async { - ToastFactory.createToast( - message: - "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요." - ) - } - } - }) - - viewController.modalPresentationStyle = .pageSheet - if let sheet = viewController.sheetPresentationController { - sheet.detents = [.medium(), .large()] - sheet.prefersGrabberVisible = true - sheet.preferredCornerRadius = 16 - } - self.present(viewController, animated: true) - } - ) - } - } - } - func checkVisited() { fetchVisitDictionaryDetailUseCase.execute() .withUnretained(self) @@ -382,6 +317,13 @@ private extension DictionaryDetailBaseViewController { owner.appCoordinator.showMainTab() } .disposed(by: disposeBag) + + mainView.bookmarkButton.rx.tap + .withUnretained(self) + .subscribe(onNext: { owner, _ in + owner.handleBookmarkTapped() + }) + .disposed(by: disposeBag) } } @@ -408,3 +350,75 @@ extension DictionaryDetailBaseViewController { .disposed(by: disposeBag) } } + +extension DictionaryDetailBaseViewController { + func handleBookmarkTapped() { + guard let isLogin = checkLogin() else { return } + + if !isLogin { + presentLoginGuide() + loginRelay?.accept(()) + return + } + + toggleBookmark() + } +} + +extension DictionaryDetailBaseViewController { + func presentAddSnackBar(bookmarkId: Int?, imageUrl: String?, background: UIColor) { + guard let bookmarkId else { return } + SnackBarFactory.createSnackBar( + type: .normal, + imageUrl: imageUrl, + imageBackgroundColor: background, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: { [weak self] in + self?.presentCollectionModal(bookmarkId: bookmarkId) + } + ) + } + + func presentDeleteSnackBar(imageUrl: String?, background: UIColor) { + SnackBarFactory.createSnackBar( + type: .delete, + imageUrl: imageUrl, + imageBackgroundColor: background, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: { [weak self] in + self?.undoBookmark() + } + ) + } + + private func presentCollectionModal(bookmarkId: Int) { + let viewController = bookmarkModalFactory.make(bookmarkIds: [bookmarkId]) { isAdd in + if isAdd { + ToastFactory.createToast(message: "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요.") + } + } + viewController.modalPresentationStyle = .pageSheet + if let sheet = viewController.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 16 + } + self.present(viewController, animated: true) + } + + func presentLoginGuide() { + GuideAlertFactory.show( + mainText: "북마크를 하려면 로그인이 필요해요.", + ctaText: "로그인 하기", + cancelText: "취소", + ctaAction: { [weak self] in + guard let self else { return } + let vc = self.loginFactory.make(exitRoute: .pop) + self.navigationController?.pushViewController(vc, animated: true) + }, + cancelAction: nil + ) + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailFactoryImpl.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailFactoryImpl.swift index f07eeb2e..c0f1a578 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailFactoryImpl.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/DictionaryDetailFactoryImpl.swift @@ -77,7 +77,7 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { self.fetchVisitDictionaryDetailUseCase = fetchVisitDictionaryDetailUseCase } - public func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(Int, Bool)>?) -> BaseViewController { + public func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, loginRelay: PublishRelay?) -> BaseViewController { var viewController = BaseViewController() switch type { case .total: @@ -92,7 +92,8 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { dictionaryDetailFactory: dictionaryDetailFactory(), detailOnBoardingFactory: detailOnBoardingFactory, appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, - bookmarkRelay: bookmarkRelay + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay ) let reactor = ItemDictionaryDetailReactor( dictionaryDetailItemUseCase: dictionaryDetailItemUseCase, @@ -112,7 +113,8 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { dictionaryDetailFactory: dictionaryDetailFactory(), detailOnBoardingFactory: detailOnBoardingFactory, appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, - bookmarkRelay: bookmarkRelay + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay ) let reactor = MonsterDictionaryDetailReactor( dictionaryDetailMonsterUseCase: dictionaryDetailMonsterUseCase, @@ -133,7 +135,8 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { dictionaryDetailFactory: dictionaryDetailFactory(), detailOnBoardingFactory: detailOnBoardingFactory, appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, - bookmarkRelay: bookmarkRelay + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay ) let reactor = MapDictionaryDetailReactor( dictionaryDetailMapUseCase: dictionaryDetailMapUseCase, @@ -154,7 +157,8 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { dictionaryDetailFactory: dictionaryDetailFactory(), detailOnBoardingFactory: detailOnBoardingFactory, appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, - bookmarkRelay: bookmarkRelay + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay ) let reactor = NpcDictionaryDetailReactor( dictionaryDetailNpcUseCase: dictionaryDetailNpcUseCase, @@ -175,7 +179,8 @@ public final class DictionaryDetailFactoryImpl: DictionaryDetailFactory { dictionaryDetailFactory: dictionaryDetailFactory(), detailOnBoardingFactory: detailOnBoardingFactory, appCoordinator: appCoordinator(), fetchVisitDictionaryDetailUseCase: fetchVisitDictionaryDetailUseCase, - bookmarkRelay: bookmarkRelay + bookmarkRelay: bookmarkRelay, + loginRelay: loginRelay ) let reactor = QuestDictionaryDetailReactor( dictionaryDetailQuestUseCase: dictionaryDetailQuestUseCase, diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift index 9eece2b5..b9e31a13 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailReactor.swift @@ -9,6 +9,14 @@ public final class ItemDictionaryDetailReactor: Reactor { case none case filter(DictionaryType) case detail(Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailItemResponse) + case delete(DictionaryDetailItemResponse) + case undo } // MARK: Action @@ -16,30 +24,29 @@ public final class ItemDictionaryDetailReactor: Reactor { case filterButtonTapped case viewWillAppear case selectFilter(SortType) - case toggleBookmark(Bool) + case toggleBookmark case undoLastDeletedBookmark case dataTapped(index: Int) } // MARK: Mutation public enum Mutation { - case toNavigate(Route) + case navigatTo(Route) case setDetailData(DictionaryDetailItemResponse) case setDetailDropMonsterData([DictionaryDetailItemDropMonsterResponse]) - case setBookmark(DictionaryDetailItemResponse) - case setLastDeletedBookmark(DictionaryDetailItemResponse?) case setLoginState(Bool) + case setEvent(UIEvent) } // MARK: State public struct State { + @Pulse var event: UIEvent = .none @Pulse var route: Route = .none var itemDetailInfo: DictionaryDetailItemResponse var type: DictionaryType = .item var monsters: [DictionaryDetailItemDropMonsterResponse] var id: Int var isLogin = false - var lastDeletedBookmark: DictionaryDetailItemResponse? } public var initialState: State @@ -64,7 +71,7 @@ public final class ItemDictionaryDetailReactor: Reactor { self.setBookmarkUseCase = setBookmarkUseCase self.initialState = .init( itemDetailInfo: DictionaryDetailItemResponse( - itemId: nil, nameKr: nil, nameEn: nil, descriptionText: nil, + itemId: 0, nameKr: nil, nameEn: nil, descriptionText: nil, imgUrl: nil, npcPrice: nil, itemType: nil, categoryHierachy: nil, availableJobs: nil, requiredStats: nil, equipmentStats: nil, scrollDetail: nil, bookmarkId: nil @@ -79,7 +86,7 @@ public final class ItemDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .filterButtonTapped: - return .just(.toNavigate(.filter(currentState.type))) + return .just(.navigatTo(.filter(currentState.type))) case .viewWillAppear: return .merge([ checkLoginUseCase.execute().map { .setLoginState($0) }, @@ -89,41 +96,15 @@ public final class ItemDictionaryDetailReactor: Reactor { case let .selectFilter(type): return dictionaryDetailItemDropMonsterUseCase.execute(id: currentState.id, sort: type.sortParameter).map { .setDetailDropMonsterData($0) } - case let .toggleBookmark(isSelected): - guard let itemId = currentState.itemDetailInfo.itemId else { return .empty() } - - let saveDeleted: Observable = isSelected - ? .just(.setLastDeletedBookmark(currentState.itemDetailInfo)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeleted.concat( - setBookmarkUseCase.execute( - bookmarkId: currentState.itemDetailInfo.bookmarkId ?? itemId, - isBookmark: isSelected ? .delete : .set(.item) - ) - .andThen( - dictionaryDetailItemUseCase.execute(id: currentState.id) - .map { .setDetailData($0) } - ) - ) + case .toggleBookmark: + return handleToggleBookmark() + case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark, - let itemId = lastDeleted.itemId else { return .empty() } - - return setBookmarkUseCase.execute( - bookmarkId: itemId, - isBookmark: .set(.item) - ) - .andThen( - Observable.concat([ - dictionaryDetailItemUseCase.execute(id: currentState.id) - .map { .setDetailData($0) }, - .just(.setLastDeletedBookmark(nil)) - ]) - ) + return handleUndoLastDeletedBookmark() + case .dataTapped(let index): guard let id = currentState.monsters[index].monsterId else { return .empty() } - return .just(.toNavigate(.detail(id))) + return .just(.navigatTo(.detail(id))) } } @@ -135,15 +116,64 @@ public final class ItemDictionaryDetailReactor: Reactor { newState.itemDetailInfo = data case let .setDetailDropMonsterData(data): newState.monsters = data - case let .setBookmark(item): - newState.itemDetailInfo = item - case let .setLastDeletedBookmark(item): - newState.lastDeletedBookmark = item case let .setLoginState(isLogin): newState.isLogin = isLogin - case .toNavigate(let route): + case .navigatTo(let route): newState.route = route + case let .setEvent(event): + newState.event = event } return newState } } + +private extension ItemDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var item = currentState.itemDetailInfo + let isSelected = item.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? item.bookmarkId ?? item.itemId : item.itemId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + item.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(item) : .add(item) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailItemUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var item = currentState.itemDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: item.itemId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + item.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(item))) + let refresh = self.dictionaryDetailItemUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift index 27b25de1..c4efe2c5 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Item/ItemDictionaryDetailViewController.swift @@ -14,6 +14,19 @@ final class ItemDictionaryDetailViewController: DictionaryDetailBaseViewControll private let detailInfoView = DetailStackInfoView(type: .item) private let monsterCardView = DetailStackCardView() private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } } // MARK: - Populate Data @@ -161,7 +174,7 @@ extension ItemDictionaryDetailViewController { bindUserAction(reactor: reactor) bindViewState(reactor: reactor) bindReportButton( - providerId: reactor.state.map { $0.itemDetailInfo.itemId ?? 0 }, + providerId: reactor.state.map { $0.itemDetailInfo.itemId }, itemName: reactor.state.map { $0.itemDetailInfo.nameKr ?? "" } ) } @@ -208,9 +221,10 @@ extension ItemDictionaryDetailViewController { .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } // 값이 바뀔때만 이벤트 받음 .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { - case .filter(let type): + case let .filter(type): guard let option = type.detailTypes.first else { return } let viewController = owner.sortedFactory.make(sortedOptions: option.sortFilter, selectedIndex: owner.selectedIndex) { index in owner.selectedIndex = index @@ -219,28 +233,37 @@ extension ItemDictionaryDetailViewController { reactor.action.onNext(.selectFilter(selectedFilter)) } owner.tabBarController?.presentModal(viewController, hideTabBar: true) - case .none: - break - case .detail(let id): - let viewController = owner.dictionaryDetailFactory.make(type: .monster, id: id, bookmarkRelay: self.bookmarkRelay) + case let .detail(id): + let viewController = owner.dictionaryDetailFactory.make(type: .monster, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break } } .disposed(by: disposeBag) - bindBookmarkButton( - buttonTap: mainView.bookmarkButton.rx.tap, - currentItem: reactor.state.map { $0.itemDetailInfo }, - isLogin: { reactor.currentState.isLogin }, - id: { _ in reactor.currentState.itemDetailInfo.itemId ?? 0 }, - imageUrl: { $0.imgUrl }, - backgroundColor: type.backgroundColor, - isBookmarked: { $0.bookmarkId != nil }, - toggleBookmark: { isDeleting in reactor.action.onNext(.toggleBookmark(isDeleting)) }, - undoLastDeleted: { reactor.action.onNext(.undoLastDeletedBookmark) }, - bookmarkId: reactor.state.map(\.itemDetailInfo.bookmarkId) - ) - .disposed(by: disposeBag) + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.imgUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.itemId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.imgUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.itemId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailReactor.swift index cc0a12b1..8ed34e3c 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailReactor.swift @@ -8,11 +8,20 @@ public final class MapDictionaryDetailReactor: Reactor { case none case filter([SortType]) case detail(type: DictionaryType, id: Int) + case bookmarkError } + + public enum UIEvent { + case none + case add(DictionaryDetailMapResponse) + case delete(DictionaryDetailMapResponse) + case undo + } + public enum Action { case monsterFilterButtonTapped case viewWillAppear - case toggleBookmark(Bool) + case toggleBookmark case undoLastDeletedBookmark case monsterTapped(index: Int) case npcTapped(index: Int) @@ -20,13 +29,14 @@ public final class MapDictionaryDetailReactor: Reactor { } public enum Mutation { - case toNavigate(Route) + case navigatTo(Route) case setDetailData(DictionaryDetailMapResponse) case setDetailSpawnMonsters([DictionaryDetailMapSpawnMonsterResponse]) case setDetailNpc([DictionaryDetailMapNpcResponse]) case setBookmark(DictionaryDetailMapResponse) case setLastDeletedBookmark(DictionaryDetailMapResponse?) case setLoginState(Bool) + case setEvent(UIEvent) } public let dictionaryDetailMapUseCase: FetchDictionaryDetailMapUseCase @@ -36,6 +46,7 @@ public final class MapDictionaryDetailReactor: Reactor { private let setBookmarkUseCase: SetBookmarkUseCase public struct State { + @Pulse var event: UIEvent = .none @Pulse var route: Route = .none var mapDetailInfo: DictionaryDetailMapResponse var spawnMonsters: [DictionaryDetailMapSpawnMonsterResponse] @@ -62,7 +73,7 @@ public final class MapDictionaryDetailReactor: Reactor { ) { initialState = State( mapDetailInfo: DictionaryDetailMapResponse( - mapId: nil, + mapId: 0, nameKr: nil, nameEn: nil, regionName: nil, @@ -88,7 +99,7 @@ public final class MapDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .monsterFilterButtonTapped: - return Observable.just(.toNavigate(.filter(currentState.monsterFilter))) + return Observable.just(.navigatTo(.filter(currentState.monsterFilter))) case .viewWillAppear: return .merge([ checkLoginUseCase.execute().map { .setLoginState($0) }, @@ -96,46 +107,22 @@ public final class MapDictionaryDetailReactor: Reactor { dictionaryDetailMapSpawnMonsterUseCase.execute(id: currentState.id, sort: nil).map {.setDetailSpawnMonsters($0)}, dictionaryDetailMapNpcUseCase.execute(id: currentState.id).map {.setDetailNpc($0)} ]) - case let .toggleBookmark(isSelected): - guard let itemId = currentState.mapDetailInfo.mapId else { return .empty() } - - let saveDeleted: Observable = isSelected - ? .just(.setLastDeletedBookmark(currentState.mapDetailInfo)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeleted.concat( - setBookmarkUseCase.execute( - bookmarkId: currentState.mapDetailInfo.bookmarkId ?? itemId, - isBookmark: isSelected ? .delete : .set(.map) - ) - .andThen( - dictionaryDetailMapUseCase.execute(id: currentState.id) - .map { .setDetailData($0) } - ) - ) + case .toggleBookmark: + return handleToggleBookmark() + + case .undoLastDeletedBookmark: + return handleUndoLastDeletedBookmark() + case let .selectFilter(type): return dictionaryDetailMapSpawnMonsterUseCase.execute(id: currentState.id, sort: type.sortParameter).map { .setDetailSpawnMonsters($0) } - case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark, - let mapId = lastDeleted.mapId else { return .empty() } - - return setBookmarkUseCase.execute( - bookmarkId: mapId, - isBookmark: .set(.map) - ) - .andThen( - Observable.concat([ - dictionaryDetailMapUseCase.execute(id: currentState.id) - .map { .setDetailData($0) }, - .just(.setLastDeletedBookmark(nil)) - ]) - ) + case .monsterTapped(index: let index): guard let id = currentState.spawnMonsters[index].monsterId else { return .empty() } - return .just(.toNavigate(.detail(type: .monster, id: id))) + + return .just(.navigatTo(.detail(type: .monster, id: id))) case .npcTapped(index: let index): guard let id = currentState.npcs[index].npcId else { return .empty() } - return .just(.toNavigate(.detail(type: .npc, id: id))) + return .just(.navigatTo(.detail(type: .npc, id: id))) } } @@ -143,7 +130,7 @@ public final class MapDictionaryDetailReactor: Reactor { var newState = state switch mutation { - case .toNavigate(let route): + case .navigatTo(let route): newState.route = route case let .setDetailData(data): newState.mapDetailInfo = data @@ -157,7 +144,60 @@ public final class MapDictionaryDetailReactor: Reactor { newState.lastDeletedBookmark = map case let .setLoginState(isLogin): newState.isLogin = isLogin + case let .setEvent(event): + newState.event = event } return newState } } + +private extension MapDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var map = currentState.mapDetailInfo + let isSelected = map.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? map.bookmarkId ?? map.mapId : map.mapId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + map.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(map) : .add(map) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailMapUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var map = currentState.mapDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: map.mapId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + map.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(map))) + let refresh = self.dictionaryDetailMapUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailViewController.swift index da01bef7..62ab5cc7 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Map/MapDictionaryDetailViewController.swift @@ -22,6 +22,19 @@ final class MapDictionaryDetailViewController: DictionaryDetailBaseViewControlle super.viewDidLoad() bindImageView() } + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } } // MARK: - SetUp @@ -116,7 +129,7 @@ extension MapDictionaryDetailViewController { func bind(reactor: Reactor) { bindUserActions(reactor: reactor) bindViewState(reactor: reactor) - bindReportButton(providerId: reactor.state.map { $0.mapDetailInfo.mapId ?? 0 }, itemName: reactor.state.map { $0.mapDetailInfo.nameKr ?? "" }) + bindReportButton(providerId: reactor.state.map { $0.mapDetailInfo.mapId }, itemName: reactor.state.map { $0.mapDetailInfo.nameKr ?? "" }) } private func bindUserActions(reactor: Reactor) { @@ -171,24 +184,11 @@ extension MapDictionaryDetailViewController { }) .disposed(by: disposeBag) - bindBookmarkButton( - buttonTap: mainView.bookmarkButton.rx.tap, - currentItem: reactor.state.map { $0.mapDetailInfo }, - isLogin: { reactor.currentState.isLogin }, - id: { _ in reactor.currentState.mapDetailInfo.mapId ?? 0 }, - imageUrl: { $0.mapUrl }, - backgroundColor: type.backgroundColor, - isBookmarked: { $0.bookmarkId != nil }, - toggleBookmark: { isDeleting in reactor.action.onNext(.toggleBookmark(isDeleting)) }, - undoLastDeleted: { reactor.action.onNext(.undoLastDeletedBookmark) }, - bookmarkId: reactor.state.map(\.mapDetailInfo.bookmarkId) - ) - .disposed(by: disposeBag) - rx.viewDidAppear .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } // 값이 바뀔때만 이벤트 받음 .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { case .filter(let sort): @@ -199,13 +199,36 @@ extension MapDictionaryDetailViewController { reactor.action.onNext(.selectFilter(selectedFilter)) } owner.tabBarController?.presentModal(viewController, hideTabBar: true) - case .none: - break case .detail(let type, let id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: self.bookmarkRelay) + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") + default: + break } } .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.mapId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.mapId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift index b355a781..0f798445 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailReactor.swift @@ -8,6 +8,7 @@ public final class MonsterDictionaryDetailReactor: Reactor { case none case filter(type: DictionaryType, sort: [SortType]) case detail(type: DictionaryType, id: Int) + case bookmarkError } public struct Info: Equatable { @@ -15,12 +16,19 @@ public final class MonsterDictionaryDetailReactor: Reactor { var desc: String } + public enum UIEvent { + case none + case add(DictionaryDetailMonsterResponse) + case delete(DictionaryDetailMonsterResponse) + case undo + } + // MARK: - Action public enum Action { case filterButtonTapped(DictionaryType) case viewWillAppear case selectFilter(SortType) - case toggleBookmark(Bool) + case toggleBookmark case undoLastDeletedBookmark case itemTapped(index: Int) case mapTapped(index: Int) @@ -28,17 +36,19 @@ public final class MonsterDictionaryDetailReactor: Reactor { // MARK: - Mutation public enum Mutation { - case toNavigate(Route) + case navigatTo(Route) case setDetailData(DictionaryDetailMonsterResponse) case setDetailDropItemData([DictionaryDetailMonsterDropItemResponse]) case setDetailMapData([DictionaryDetailMonsterMapResponse]) case setBookmark(DictionaryDetailMonsterResponse) case setLastDeletedBookmark(DictionaryDetailMonsterResponse?) case setLoginState(Bool) + case setEvent(UIEvent) } // MARK: - State public struct State { + @Pulse var event: UIEvent = .none @Pulse var route: Route = .none var type: DictionaryType = .monster var id = 0 @@ -95,7 +105,7 @@ public final class MonsterDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case let .filterButtonTapped(type): - return .just(.toNavigate(.filter(type: type, sort: type == .map ? currentState.mapFilter : currentState.itemFilter))) + return .just(.navigatTo(.filter(type: type, sort: type == .map ? currentState.mapFilter : currentState.itemFilter))) case .viewWillAppear: return .merge([ @@ -108,39 +118,17 @@ public final class MonsterDictionaryDetailReactor: Reactor { case let .selectFilter(type): return dictionaryDetailMonsterDropItemUseCase.execute(id: currentState.id, sort: type.sortParameter).map { .setDetailDropItemData($0) } - case let .toggleBookmark(isSelected): - let monsterId = currentState.monsterDetailInfo.monsterId - - let saveDeleted: Observable = isSelected - ? .just(.setLastDeletedBookmark(currentState.monsterDetailInfo)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeleted.concat( - setBookmarkUseCase.execute( - bookmarkId: currentState.monsterDetailInfo.bookmarkId ?? monsterId, - isBookmark: isSelected ? .delete : .set(.monster) - ) - .andThen(dictionaryDetailMonsterUseCase.execute(id: currentState.id).map { .setDetailData($0) }) - ) + case .toggleBookmark: + return handleToggleBookmark() case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } - let monsterId = lastDeleted.monsterId - - return setBookmarkUseCase.execute( - bookmarkId: monsterId, - isBookmark: .set(.monster) - ) - .andThen( - Observable.concat([ - dictionaryDetailMonsterUseCase.execute(id: currentState.id).map { .setDetailData($0) }, - .just(.setLastDeletedBookmark(nil)) - ]) - ) + return handleUndoLastDeletedBookmark() + case .itemTapped(index: let index): - return .just(.toNavigate(.detail(type: .item, id: currentState.dropItems[index].itemId))) + return .just(.navigatTo(.detail(type: .item, id: currentState.dropItems[index].itemId))) + case .mapTapped(index: let index): - return .just(.toNavigate(.detail(type: .map, id: currentState.spawnMaps[index].mapId))) + return .just(.navigatTo(.detail(type: .map, id: currentState.spawnMaps[index].mapId))) } } @@ -149,7 +137,7 @@ public final class MonsterDictionaryDetailReactor: Reactor { var newState = state switch mutation { - case let .toNavigate(route): + case let .navigatTo(route): newState.route = route case let .setDetailData(data): @@ -177,8 +165,61 @@ public final class MonsterDictionaryDetailReactor: Reactor { case let .setLoginState(isLogin): newState.isLogin = isLogin + case let .setEvent(event): + newState.event = event } return newState } } + +private extension MonsterDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var monster = currentState.monsterDetailInfo + let isSelected = monster.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? monster.bookmarkId ?? monster.monsterId : monster.monsterId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + monster.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(monster) : .add(monster) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailMonsterUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var monster = currentState.monsterDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: monster.monsterId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + monster.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(monster))) + let refresh = self.dictionaryDetailMonsterUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift index c386e132..2e4e7ca7 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Monster/MonsterDictionaryDetailViewController.swift @@ -1,5 +1,6 @@ import UIKit +import BaseFeature import DesignSystem import DictionaryFeatureInterface import DomainInterface @@ -20,6 +21,19 @@ class MonsterDictionaryDetailViewController: DictionaryDetailBaseViewController, private var appearMapView = DetailStackCardView() private var dropItemView = DetailStackCardView() private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } } // MARK: - Populate Data @@ -186,6 +200,7 @@ extension MonsterDictionaryDetailViewController { .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } // 값이 바뀔때만 이벤트 받음 .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { case .filter(let type, let sort): @@ -206,26 +221,35 @@ extension MonsterDictionaryDetailViewController { } owner.tabBarController?.presentModal(viewController, hideTabBar: true) case let .detail(type: type, id: id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: self.bookmarkRelay) + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") default: break } } .disposed(by: disposeBag) - bindBookmarkButton( - buttonTap: mainView.bookmarkButton.rx.tap, - currentItem: reactor.state.map { $0.monsterDetailInfo }, - isLogin: { reactor.currentState.isLogin }, - id: { _ in reactor.currentState.monsterDetailInfo.monsterId }, - imageUrl: { $0.imageUrl }, - backgroundColor: type.backgroundColor, - isBookmarked: { $0.bookmarkId != nil }, - toggleBookmark: { isDeleting in reactor.action.onNext(.toggleBookmark(isDeleting)) }, - undoLastDeleted: { reactor.action.onNext(.undoLastDeletedBookmark) }, - bookmarkId: reactor.state.map(\.monsterDetailInfo.bookmarkId) - ) - .disposed(by: disposeBag) + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.imageUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.monsterId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.imageUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.monsterId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift index d643633f..1857182c 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailReactor.swift @@ -8,6 +8,14 @@ public final class NpcDictionaryDetailReactor: Reactor { case none case filter([SortType]) case detail(type: DictionaryType, id: Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailNpcResponse) + case delete(DictionaryDetailNpcResponse) + case undo } // MARK: - Action @@ -15,7 +23,7 @@ public final class NpcDictionaryDetailReactor: Reactor { case filterButtonTapped case viewWillAppear case selectFilter(SortType) - case toggleBookmark(Bool) + case toggleBookmark case undoLastDeletedBookmark case mapTapped(index: Int) case questTapped(index: Int) @@ -23,16 +31,18 @@ public final class NpcDictionaryDetailReactor: Reactor { // MARK: - Mutation public enum Mutation { - case toNavigate(Route) + case navigatTo(Route) case setDetailData(DictionaryDetailNpcResponse) case setDetailMaps([DictionaryDetailMonsterMapResponse]) case setDetailQuests([DictionaryDetailNpcQuestResponse]) case setLoginState(Bool) case setLastDeletedBookmark(DictionaryDetailNpcResponse?) + case setEvent(UIEvent) } // MARK: - State public struct State { + @Pulse var event: UIEvent = .none @Pulse var route: Route = .none var npcDetailInfo: DictionaryDetailNpcResponse var type: DictionaryType = .npc @@ -84,7 +94,7 @@ public final class NpcDictionaryDetailReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case .filterButtonTapped: - return .just(.toNavigate(.filter(currentState.questFilter))) + return .just(.navigatTo(.filter(currentState.questFilter))) case .viewWillAppear: return .merge([ checkLoginUseCase.execute().map { .setLoginState($0) }, @@ -95,43 +105,17 @@ public final class NpcDictionaryDetailReactor: Reactor { case let .selectFilter(type): return dictionaryDetailNpcQuestUseCase.execute(id: currentState.id, sort: type.sortParameter).map { .setDetailQuests($0) } - case let .toggleBookmark(isSelected): - let npcId = currentState.npcDetailInfo.npcId - - let saveDeleted: Observable = isSelected - ? .just(.setLastDeletedBookmark(currentState.npcDetailInfo)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeleted.concat( - setBookmarkUseCase.execute( - bookmarkId: currentState.npcDetailInfo.bookmarkId ?? npcId, - isBookmark: isSelected ? .delete : .set(.npc) - ) - .andThen( - dictionaryDetailNpcUseCase.execute(id: currentState.id) - .map { .setDetailData($0) } - ) - ) + case .toggleBookmark: + return handleToggleBookmark() case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } - let npcId = lastDeleted.npcId - - return setBookmarkUseCase.execute( - bookmarkId: npcId, - isBookmark: .set(.npc) - ) - .andThen( - Observable.concat([ - dictionaryDetailNpcUseCase.execute(id: currentState.id) - .map { .setDetailData($0) }, - .just(.setLastDeletedBookmark(nil)) - ]) - ) + return handleUndoLastDeletedBookmark() + case .mapTapped(index: let index): - return .just(.toNavigate(.detail(type: .map, id: currentState.maps[index].mapId))) + return .just(.navigatTo(.detail(type: .map, id: currentState.maps[index].mapId))) + case .questTapped(index: let index): - return .just(.toNavigate(.detail(type: .quest, id: currentState.quests[index].questId))) + return .just(.navigatTo(.detail(type: .quest, id: currentState.quests[index].questId))) } } @@ -139,7 +123,7 @@ public final class NpcDictionaryDetailReactor: Reactor { public func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { - case .toNavigate(let route): + case .navigatTo(let route): newState.route = route case let .setDetailData(data): newState.npcDetailInfo = data @@ -151,7 +135,60 @@ public final class NpcDictionaryDetailReactor: Reactor { newState.isLogin = isLogin case let .setLastDeletedBookmark(map): newState.lastDeletedBookmark = map + case let .setEvent(event): + newState.event = event } return newState } } + +private extension NpcDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var npc = currentState.npcDetailInfo + let isSelected = npc.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? npc.bookmarkId ?? npc.npcId : npc.npcId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + npc.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(npc) : .add(npc) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailNpcUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var npc = currentState.npcDetailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: npc.npcId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + npc.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(npc))) + let refresh = self.dictionaryDetailNpcUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift index b5aef1f7..04d448ef 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/NPC/NpcDictionaryDetailViewController.swift @@ -1,5 +1,6 @@ import UIKit +import BaseFeature import DesignSystem import DictionaryFeatureInterface import DomainInterface @@ -15,6 +16,19 @@ final class NpcDictionaryDetailViewController: DictionaryDetailBaseViewControlle private var appearMapView = DetailStackCardView() private var questView = DetailStackCardView() private let sortedFactory: SortedBottomSheetFactory = SortedBottomSheetFactoryImpl() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } } // MARK: - SetUp @@ -116,6 +130,7 @@ extension NpcDictionaryDetailViewController { .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } // 값이 바뀔때만 이벤트 받음 .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { case .filter(let type): @@ -127,8 +142,10 @@ extension NpcDictionaryDetailViewController { } owner.tabBarController?.presentModal(viewController, hideTabBar: true) case .detail(type: let type, id: let id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: self.bookmarkRelay) + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") default: break } @@ -163,18 +180,25 @@ extension NpcDictionaryDetailViewController { }) .disposed(by: disposeBag) - bindBookmarkButton( - buttonTap: mainView.bookmarkButton.rx.tap, - currentItem: reactor.state.map { $0.npcDetailInfo }, - isLogin: { reactor.currentState.isLogin }, - id: { _ in reactor.currentState.npcDetailInfo.npcId }, - imageUrl: { $0.iconUrlDetail }, - backgroundColor: type.backgroundColor, - isBookmarked: { $0.bookmarkId != nil }, - toggleBookmark: { isDeleting in reactor.action.onNext(.toggleBookmark(isDeleting)) }, - undoLastDeleted: { reactor.action.onNext(.undoLastDeletedBookmark) }, - bookmarkId: reactor.state.map(\.npcDetailInfo.bookmarkId) - ) - .disposed(by: disposeBag) + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.iconUrlDetail, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.npcId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.iconUrlDetail, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.npcId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift index 42a0835f..8e35deb5 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailReactor.swift @@ -18,25 +18,35 @@ public final class QuestDictionaryDetailReactor: Reactor { case none case filter(DictionaryType) case detail(type: DictionaryType, id: Int) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryDetailQuestResponse) + case delete(DictionaryDetailQuestResponse) + case undo } public enum Action { case viewWillAppear - case toggleBookmark(Bool) + case toggleBookmark case undoLastDeletedBookmark case questTapped(index: Int) case infoTapped(type: DictionaryType, id: Int) } public enum Mutation { - case toNavigate(Route) + case navigatTo(Route) case setDetailData(DictionaryDetailQuestResponse) case setLinkedQuests(DictionaryDetailQuestLinkedQuestsResponse) case setLoginState(Bool) case setLastDeletedBookmark(DictionaryDetailQuestResponse?) + case setEvent(UIEvent) } public struct State { + @Pulse var event: UIEvent = .none @Pulse var route: Route = .none var type: DictionaryType = .quest var id: Int @@ -69,7 +79,7 @@ public final class QuestDictionaryDetailReactor: Reactor { self.initialState = .init( id: id, detailInfo: .init( - questId: nil, + questId: 0, titlePrefix: nil, nameKr: nil, nameEn: nil, @@ -101,46 +111,20 @@ public final class QuestDictionaryDetailReactor: Reactor { dictionaryDetailQuestLinkedQuestUseCase.execute(id: currentState.id).map { .setLinkedQuests($0) } ]) - case let .toggleBookmark(isSelected): - guard let questId = currentState.detailInfo.questId else { return .empty() } - - let saveDeleted: Observable = isSelected - ? .just(.setLastDeletedBookmark(currentState.detailInfo)) - : .just(.setLastDeletedBookmark(nil)) - - return saveDeleted.concat( - setBookmarkUseCase.execute( - bookmarkId: currentState.detailInfo.bookmarkId ?? questId, - isBookmark: isSelected ? .delete : .set(.quest) - ) - .andThen( - dictionaryDetailQuestUseCase.execute(id: currentState.id) - .map { .setDetailData($0) } - ) - ) + case .toggleBookmark: + return handleToggleBookmark() case .undoLastDeletedBookmark: - guard let lastDeleted = currentState.lastDeletedBookmark, - let questId = lastDeleted.questId else { return .empty() } + return handleUndoLastDeletedBookmark() - return setBookmarkUseCase.execute( - bookmarkId: questId, - isBookmark: .set(.quest) - ) - .andThen( - Observable.concat([ - dictionaryDetailQuestUseCase.execute(id: currentState.id) - .map { .setDetailData($0) }, - .just(.setLastDeletedBookmark(nil)) - ]) - ) case let .questTapped(index): let tappedQuestInfo = currentState.totalQuest[index] guard let id = tappedQuestInfo.quest.questId, tappedQuestInfo.type != .current else { return .empty() } - return .just(.toNavigate(.detail(type: .quest, id: id))) + return .just(.navigatTo(.detail(type: .quest, id: id))) + case let .infoTapped(type: type, id: id): - return .just(.toNavigate(.detail(type: type, id: id))) + return .just(.navigatTo(.detail(type: type, id: id))) } } @@ -163,8 +147,10 @@ public final class QuestDictionaryDetailReactor: Reactor { newState.isLogin = isLogin case let .setLastDeletedBookmark(data): newState.lastDeletedBookmark = data - case let .toNavigate(route): + case let .navigatTo(route): newState.route = route + case let .setEvent(event): + newState.event = event } return newState } @@ -182,16 +168,14 @@ extension QuestDictionaryDetailReactor { quests.append(contentsOf: mapped) } - if let currentId = detailInfo.questId { - let currentQuest = Quest( - questId: currentId, - name: detailInfo.nameKr ?? "", - minLevel: detailInfo.minLevel, - maxLevel: detailInfo.maxLevel, - iconUrl: detailInfo.iconUrl - ) - quests.append(QuestInfo(quest: currentQuest, type: .current)) - } + let currentQuest = Quest( + questId: detailInfo.questId, + name: detailInfo.nameKr ?? "", + minLevel: detailInfo.minLevel, + maxLevel: detailInfo.maxLevel, + iconUrl: detailInfo.iconUrl + ) + quests.append(QuestInfo(quest: currentQuest, type: .current)) if let next = linkedInfo.nextQuests { let mapped = next.map { QuestInfo(quest: $0, type: .next) } @@ -201,3 +185,54 @@ extension QuestDictionaryDetailReactor { return quests } } + +private extension QuestDictionaryDetailReactor { + func handleToggleBookmark() -> Observable { + var quest = currentState.detailInfo + let isSelected = quest.bookmarkId != nil + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: isSelected ? quest.bookmarkId ?? quest.questId : quest.questId, + isBookmark: isSelected ? .delete : .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + quest.bookmarkId = newBookmarkId + let event: UIEvent = isSelected ? .delete(quest) : .add(quest) + let eventMutation = Observable.just(Mutation.setEvent(event)) + + let refresh = self.dictionaryDetailQuestUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUndoLastDeletedBookmark() -> Observable { + var quest = currentState.detailInfo + guard let type = currentState.type.toItemType else { return .empty() } + + return setBookmarkUseCase.execute( + bookmarkId: quest.questId, + isBookmark: .set(type) + ) + .flatMap { [weak self] newBookmarkId -> Observable in + guard let self else { return .empty() } + + quest.bookmarkId = newBookmarkId + let eventMutation = Observable.just(Mutation.setEvent(.add(quest))) + let refresh = self.dictionaryDetailQuestUseCase.execute(id: self.currentState.id) + .map { Mutation.setDetailData($0) } + + return .concat([eventMutation, refresh]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift index 3eea7f99..25e88982 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryDetail/Quest/QuestDictionaryDetailViewController.swift @@ -1,5 +1,6 @@ import UIKit +import BaseFeature import DesignSystem import DomainInterface @@ -12,6 +13,19 @@ final class QuestDictionaryDetailViewController: DictionaryDetailBaseViewControl // MARK: - Components private var detailInfoView = DetailStackInfoView(type: .quest) private var linkedQuestView = DetailStackCardView() + + override func toggleBookmark() { + reactor?.action.onNext(.toggleBookmark) + } + + override func checkLogin() -> Bool { + guard let reactor = reactor else { return false } + return reactor.currentState.isLogin + } + + override func undoBookmark() { + reactor?.action.onNext(.undoLastDeletedBookmark) + } } // MARK: - Populate Data @@ -142,7 +156,7 @@ extension QuestDictionaryDetailViewController { public func bind(reactor: Reactor) { bindUserAction(reactor: reactor) bindViewState(reactor: reactor) - bindReportButton(providerId: reactor.state.map { $0.detailInfo.questId ?? 0 }, itemName: reactor.state.map { $0.detailInfo.nameKr ?? "" }) + bindReportButton(providerId: reactor.state.map { $0.detailInfo.questId }, itemName: reactor.state.map { $0.detailInfo.nameKr ?? "" }) } private func bindUserAction(reactor: Reactor) { @@ -178,33 +192,43 @@ extension QuestDictionaryDetailViewController { }) .disposed(by: disposeBag) - bindBookmarkButton( - buttonTap: mainView.bookmarkButton.rx.tap, - currentItem: reactor.state.map { $0.detailInfo }, - isLogin: { reactor.currentState.isLogin }, - id: { _ in reactor.currentState.detailInfo.questId ?? 0 }, - imageUrl: { $0.iconUrl }, - backgroundColor: type.backgroundColor, - isBookmarked: { $0.bookmarkId != nil }, - toggleBookmark: { isDeleting in reactor.action.onNext(.toggleBookmark(isDeleting)) }, - undoLastDeleted: { reactor.action.onNext(.undoLastDeletedBookmark) }, - bookmarkId: reactor.state.map(\.detailInfo.bookmarkId) - ) - .disposed(by: disposeBag) - rx.viewDidAppear .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { case let .detail(type, id): - let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: self.bookmarkRelay) + let viewController = owner.dictionaryDetailFactory.make(type: type, id: id, bookmarkRelay: owner.bookmarkRelay, loginRelay: owner.loginRelay) owner.navigationController?.pushViewController(viewController, animated: true) + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") default: break } } .disposed(by: disposeBag) + + reactor.pulse(\.$event) + .bind(onNext: { [weak self] event in + switch event { + case let .add(item): + self?.presentAddSnackBar( + bookmarkId: item.bookmarkId, + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.questId, item.bookmarkId)) + case let .delete(item): + self?.presentDeleteSnackBar( + imageUrl: item.iconUrl, + background: DictionaryItemType.item.backgroundColor + ) + self?.bookmarkRelay?.accept((item.questId, item.bookmarkId)) + default: break + } + }) + .disposed(by: disposeBag) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListReactor.swift index 2e9648e5..7be1f4a9 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListReactor.swift @@ -5,12 +5,20 @@ import ReactorKit import RxSwift public final class DictionaryListReactor: Reactor { - - // MARK: - Route + // MARK: - Type public enum Route { case none case sort(DictionaryType) case filter(DictionaryType) + case bookmarkError + } + + public enum UIEvent { + case none + case add(DictionaryMainItemResponse) + case delete(DictionaryMainItemResponse) + case undo + case login } // MARK: - Action @@ -24,14 +32,15 @@ public final class DictionaryListReactor: Reactor { case setCurrentPage case fetchList case undoLastDeletedBookmark - case toggleBookmark(id: Int, isSelected: Bool) + case toggleBookmark(id: Int) + case showLogin + case updateBookmark(id: Int, newBookmarkId: Int?) } // MARK: - Mutation public enum Mutation { + case navigatTo(Route) case setListItem(DictionaryMainResponse, updateBookmarkOnly: Bool = false) - case showSortFilter - case showFilter case setSort(String) case setFilter(start: Int?, end: Int?) case setCurrentPage @@ -40,12 +49,14 @@ public final class DictionaryListReactor: Reactor { case setLastDeletedBookmark(DictionaryMainItemResponse?) case setJobId([Int]) case setCategoryId([Int]) - case updateBookmarkState(id: Int, isSelected: Bool) - case updateBookmarkStates([Int: Bool]) // 새 Mutation: 여러 북마크 반영 + case updateBookmarkId(id: Int, newBookmarkId: Int?) + case setFirstFetch(Bool) + case setEvent(UIEvent) } // MARK: - State public struct State { + @Pulse var uiEvent: UIEvent = .none @Pulse var route: Route public var listItems: [DictionaryMainItemResponse] = [] public var type: DictionaryType @@ -62,7 +73,8 @@ public final class DictionaryListReactor: Reactor { var isLogin: Bool var lastDeletedBookmark: DictionaryMainItemResponse? - var isBookmarkUpdateOnly: Bool = false + var isBookmarkUpdateOnly = false + var isFirstFetch = true } public var initialState: State @@ -112,9 +124,9 @@ public final class DictionaryListReactor: Reactor { case .viewWillAppear: return handleViewWillAppear() case .sortButtonTapped: - return .just(.showSortFilter) + return .just(.navigatTo(.sort(currentState.type))) case .filterButtonTapped: - return .just(.showFilter) + return .just(.navigatTo(.filter(currentState.type))) case let .sortOptionSelected(sort): return handleSortOptionSelected(sort: sort) case let .filterOptionSelected(startLevel, endLevel): @@ -125,10 +137,14 @@ public final class DictionaryListReactor: Reactor { return fetchList(sort: currentState.sort, startLevel: currentState.startLevel, endLevel: currentState.endLevel) case .undoLastDeletedBookmark: return handleUndoLastDeletedBookmark() - case let .toggleBookmark(id, isSelected): - return handleToggleBookmark(id: id, isSelected: isSelected) + case let .toggleBookmark(id): + return handleToggleBookmark(id: id) case let .itemFilterOptionSelected(results): return handleItemFilterOptionSelected(results: results) + case .showLogin: + return .just(.setEvent(.login)) + case let .updateBookmark(id, newBookmarkId): + return handleUpdateBookmark(id: id, newBookmarkId: newBookmarkId) } } @@ -136,10 +152,8 @@ public final class DictionaryListReactor: Reactor { public func reduce(state: State, mutation: Mutation) -> State { var newState = state switch mutation { - case .showSortFilter: - newState.route = .sort(newState.type) - case .showFilter: - newState.route = .filter(newState.type) + case let .navigatTo(route): + newState.route = route case let .setListItem(items, updateBookmarkOnly): newState.isBookmarkUpdateOnly = updateBookmarkOnly newState.totalCounts = items.totalElements @@ -147,7 +161,7 @@ public final class DictionaryListReactor: Reactor { newState.listItems = newState.listItems.map { item in if let updated = items.contents.first(where: { $0.id == item.id }) { var copy = item - copy.bookmarkId = updated.bookmarkId ?? item.bookmarkId + copy.bookmarkId = updated.bookmarkId return copy } else { return item } } @@ -177,16 +191,14 @@ public final class DictionaryListReactor: Reactor { newState.jobId = id case let .setCategoryId(id): newState.categoryIds = id - case let .updateBookmarkState(id, isSelected): + case let .setFirstFetch(isFirstFetch): + newState.isFirstFetch = isFirstFetch + case let .updateBookmarkId(id, newBookmarkId): if let index = newState.listItems.firstIndex(where: { $0.id == id }) { - newState.listItems[index].bookmarkId = isSelected ? (newState.listItems[index].bookmarkId ?? -1) : nil - } - case let .updateBookmarkStates(dict): - for index in 0.. Observable { let response: Observable @@ -252,64 +263,71 @@ private extension DictionaryListReactor { return .empty() } - return response.map { response in - if updateBookmarkOnly { - let merged = self.currentState.listItems.map { item in - if let updated = response.contents.first(where: { $0.id == item.id }) { - var copy = item - copy.bookmarkId = updated.bookmarkId ?? item.bookmarkId - return copy - } else { return item } - } - var newResponse = response - newResponse.contents = merged - return .setListItem(newResponse, updateBookmarkOnly: true) - } else { - return .setListItem(response) - } - } + return response.map { .setListItem($0, updateBookmarkOnly: updateBookmarkOnly) } } func handleViewWillAppear() -> Observable { let loginState = checkLoginUseCase.execute() .map { Mutation.setLoginState($0) } - let fetchMutation = fetchList(sort: currentState.sort, startLevel: currentState.startLevel, endLevel: currentState.endLevel) - return .merge([loginState, fetchMutation]) - } - func handleToggleBookmark(id: Int, isSelected: Bool) -> Observable { - guard let index = currentState.listItems.firstIndex(where: { $0.id == id }) else { return .empty() } - let targetItem = currentState.listItems[index] + let fetchMutation: Observable - let optimistic: Observable - - if isSelected { - // 삭제되는 경우, undo를 위해 lastDeletedBookmark 저장 - optimistic = Observable.concat([ - // UI 반영 - .just(.updateBookmarkState(id: id, isSelected: false)), - // undo 저장 - .just(.setLastDeletedBookmark(targetItem)) - ]) + if currentState.isFirstFetch { + let firstFetch = Observable.just(Mutation.setFirstFetch(false)) + let fetch = fetchList( + sort: currentState.sort, + startLevel: currentState.startLevel, + endLevel: currentState.endLevel + ) + fetchMutation = .concat([firstFetch, fetch]) } else { - // 북마크 추가 - optimistic = .just(.updateBookmarkState(id: id, isSelected: true)) + fetchMutation = fetchList( + sort: currentState.sort, + startLevel: currentState.startLevel, + endLevel: currentState.endLevel, + updateBookmarkOnly: true + ) } - // 서버 호출 + bookmark 확정 - let api = setBookmarkUseCase.execute( + return .merge([loginState, fetchMutation]) + } + + func handleToggleBookmark(id: Int) -> Observable { + guard let index = currentState.listItems.firstIndex(where: { $0.id == id }) else { + return .empty() + } + + let targetItem = currentState.listItems[index] + let isSelected = targetItem.bookmarkId != nil + + return setBookmarkUseCase.execute( bookmarkId: isSelected ? targetItem.bookmarkId ?? targetItem.id : targetItem.id, isBookmark: isSelected ? .delete : .set(targetItem.type) ) - .andThen(fetchList( - sort: currentState.sort, - startLevel: currentState.startLevel, - endLevel: currentState.endLevel, - updateBookmarkOnly: true - )) - .observe(on: MainScheduler.asyncInstance) - - return .concat([optimistic, api]) + .flatMap { newBookmarkId -> Observable in + let lastItem = Mutation.setLastDeletedBookmark(targetItem) + + let event: UIEvent = isSelected ? .delete(targetItem) : .add(targetItem) + let eventMutation = Mutation.setEvent(event) + + let updateMutation = Mutation.updateBookmarkId(id: id, newBookmarkId: newBookmarkId) + return .from([lastItem, updateMutation, eventMutation]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } + } + + func handleUpdateBookmark(id: Int, newBookmarkId: Int?) -> Observable { + guard let index = currentState.listItems.firstIndex(where: { $0.id == id }) else { + return .empty() + } + + guard currentState.listItems[index].bookmarkId != newBookmarkId else { + return .empty() + } + + return .just(.updateBookmarkId(id: id, newBookmarkId: newBookmarkId)) } func handleSortOptionSelected(sort: SortType) -> Observable { @@ -337,17 +355,22 @@ private extension DictionaryListReactor { func handleUndoLastDeletedBookmark() -> Observable { guard let lastDeleted = currentState.lastDeletedBookmark else { return .empty() } - let optimistic = Observable.just(Mutation.updateBookmarkState(id: lastDeleted.id, isSelected: true)) - .observe(on: MainScheduler.asyncInstance) - - let apiCall = setBookmarkUseCase.execute( + return setBookmarkUseCase.execute( bookmarkId: lastDeleted.id, isBookmark: .set(lastDeleted.type) ) - .andThen(Observable.just(Mutation.setLastDeletedBookmark(nil))) - .observe(on: MainScheduler.asyncInstance) + .flatMap { newBookmarkId -> Observable in + let lastItem = Mutation.setLastDeletedBookmark(nil) + + let event: UIEvent = .add(lastDeleted) + let eventMutation = Mutation.setEvent(event) - return .concat([optimistic, apiCall]) + let updateMutation = Mutation.updateBookmarkId(id: lastDeleted.id, newBookmarkId: newBookmarkId) + return .from([lastItem, updateMutation, eventMutation]) + } + .catch { _ in + .just(.navigatTo(.bookmarkError)) + } } func handleItemFilterOptionSelected(results: [(String, String)]) -> Observable { diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListViewController.swift index 0fed74a2..6f22ceef 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryList/DictionaryListViewController.swift @@ -23,7 +23,8 @@ public final class DictionaryListViewController: BaseViewController, View { private var selectedSortIndex = 0 public let itemCountRelay = PublishRelay() - private let bookmarkChangeRelay = PublishRelay<(Int, Bool)>() + private let bookmarkChangeRelay = PublishRelay<(id: Int, newBookmarkId: Int?)>() + private var loginRelay = PublishRelay() private var lastPagingTime: Date = .distantPast // MARK: - Components @@ -119,62 +120,88 @@ extension DictionaryListViewController { } func bindViewState(reactor: Reactor) { + bindItemCount(reactor: reactor) + bindLifecycle(reactor: reactor) + bindRoute(reactor: reactor) + bindTypeChanges(reactor: reactor) + bindBookmarkChange() + bindListItems() + bindUIEvents(reactor: reactor) + } + + // MARK: - Sub-binds + + private func bindItemCount(reactor: Reactor) { reactor.state .map { $0.totalCounts } .distinctUntilChanged() .bind(to: itemCountRelay) .disposed(by: disposeBag) + } + private func bindLifecycle(reactor: Reactor) { rx.viewWillAppear .map { _ in Reactor.Action.viewWillAppear } .bind(to: reactor.action) .disposed(by: disposeBag) + } + private func bindRoute(reactor: Reactor) { rx.viewDidAppear .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } .withUnretained(self) - .subscribe { owner, route in + .observe(on: MainScheduler.instance) + .subscribe(onNext: { owner, route in switch route { case .sort(let type): let viewController = owner.sortedFactory.make( sortedOptions: type.bookmarkSortedFilter, selectedIndex: owner.selectedSortIndex - ) { index in - owner.selectedSortIndex = index + ) { [weak self, weak reactor] index in + guard let self, let reactor else { return } + self.selectedSortIndex = index let selectedFilter = reactor.currentState.type.bookmarkSortedFilter[index] reactor.action.onNext(.sortOptionSelected(selectedFilter)) - owner.mainView.selectSort(selectedType: selectedFilter) + self.mainView.selectSort(selectedType: selectedFilter) } owner.tabBarController?.presentModal(viewController) case .filter(let type): switch type { case .item: - let viewController = owner.itemFilterFactory.make { results in + let viewController = owner.itemFilterFactory.make { [weak self, weak reactor] results in + guard let self, let reactor else { return } reactor.action.onNext(.itemFilterOptionSelected(results)) if results.isEmpty { - owner.mainView.resetFilter() + self.mainView.resetFilter() } else { - owner.mainView.selectFilter() + self.mainView.selectFilter() } } owner.present(viewController, animated: true) case .monster: - let viewController = owner.monsterFilterFactory.make(startLevel: reactor.currentState.startLevel ?? 0, endLevel: reactor.currentState.endLevel ?? 200) { startLevel, endLevel in - owner.mainView.selectFilter() - reactor.action.onNext(.filterOptionSelected(startLevel: startLevel, endLevel: endLevel)) + let viewController = owner.monsterFilterFactory.make( + startLevel: reactor.currentState.startLevel ?? 0, + endLevel: reactor.currentState.endLevel ?? 200 + ) { [weak self, weak reactor] startLevel, endLevel in + self?.mainView.selectFilter() + reactor?.action.onNext(.filterOptionSelected(startLevel: startLevel, endLevel: endLevel)) } owner.tabBarController?.presentModal(viewController) default: break } + case .bookmarkError: + ToastFactory.createToast(message: "북마크 요청에 실패했어요. 다시 시도해주세요.") default: break } - } + }) .disposed(by: disposeBag) + } + private func bindTypeChanges(reactor: Reactor) { reactor.state .map(\.type) .distinctUntilChanged() @@ -183,47 +210,138 @@ extension DictionaryListViewController { owner.mainView.updateFilter(sortType: type.sortedFilter.first) }) .disposed(by: disposeBag) + } + private func bindBookmarkChange() { bookmarkChangeRelay + .withUnretained(self) .observe(on: MainScheduler.instance) - .bind(onNext: { [weak self] id, isBookmarked in - self?.reactor?.action.onNext(.toggleBookmark(id: id, isSelected: isBookmarked)) + .bind(onNext: { owner, bookmarkResult in + let (id, newBookmarkId) = bookmarkResult + owner.reactor?.action.onNext(.updateBookmark(id: id, newBookmarkId: newBookmarkId)) }) .disposed(by: disposeBag) + } - // 기존 bookmarkChangeRelay 사용 대신 - reactor.state.map(\.listItems) - .observe(on: MainScheduler.instance) + private func bindListItems() { + reactor?.state.map(\.listItems) .withUnretained(self) .observe(on: MainScheduler.instance) .bind { owner, items in - let collectionView = owner.mainView.listCollectionView - owner.mainView.checkEmptyData(isEmpty: items.isEmpty) - - guard let reactor = owner.reactor else { return } - if reactor.currentState.currentPage == 0, !reactor.currentState.isBookmarkUpdateOnly { - collectionView.reloadData() - } else { - let startIndex = collectionView.numberOfItems(inSection: 0) - let endIndex = items.count - if endIndex > startIndex { - let indexPaths = (startIndex ..< endIndex).map { IndexPath(item: $0, section: 0) } - collectionView.performBatchUpdates { - collectionView.insertItems(at: indexPaths) - } - } + owner.updateCollectionView(items: items) + } + .disposed(by: disposeBag) + } - for cell in collectionView.visibleCells { - if let indexPath = collectionView.indexPath(for: cell), - indexPath.item < items.count, - let cell = cell as? DictionaryListCell { - let item = items[indexPath.item] - cell.updateBookmarkState(isBookmarked: item.bookmarkId != nil) - } + private func bindUIEvents(reactor: Reactor) { + rx.viewDidAppear + .take(1) + .flatMapLatest { _ in reactor.pulse(\.$uiEvent) } + .withUnretained(self) + .subscribe(onNext: { owner, event in + switch event { + case .add(let item): + owner.presentAddSnackBar(item: item) + case .delete(let item): + owner.presentDeleteSnackBar(item: item) + case .login: + owner.presentLoginGuide() + default: + break + } + }) + .disposed(by: disposeBag) + } + + private func presentAddSnackBar(item: DictionaryMainItemResponse) { + SnackBarFactory.createSnackBar( + type: .normal, + imageUrl: item.imageUrl, + imageBackgroundColor: item.type.backgroundColor, + text: "아이템을 북마크에 추가했어요.", + buttonText: "컬렉션 추가", + buttonAction: { [weak self] in + self?.reactor?.state.map(\.listItems) + .compactMap { list in + list.first(where: { $0.id == item.id })?.bookmarkId } + .take(1) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] bookmarkId in + guard let self else { return } + let vc = self.bookmarkModalFactory.make(bookmarkIds: [bookmarkId]) { isAdd in + if isAdd { + ToastFactory.createToast( + message: "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요." + ) + } + } + vc.modalPresentationStyle = .pageSheet + if let sheet = vc.sheetPresentationController { + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 16 + } + self.present(vc, animated: true) + }) + .disposed(by: self?.disposeBag ?? DisposeBag()) + } + ) + } + + private func presentDeleteSnackBar(item: DictionaryMainItemResponse) { + SnackBarFactory.createSnackBar( + type: .delete, + imageUrl: item.imageUrl, + imageBackgroundColor: item.type.backgroundColor, + text: "아이템을 북마크에서 삭제했어요.", + buttonText: "되돌리기", + buttonAction: { [weak self] in + self?.reactor?.action.onNext(.undoLastDeletedBookmark) + } + ) + } + + private func presentLoginGuide() { + GuideAlertFactory.show( + mainText: "북마크를 하려면 로그인이 필요해요.", + ctaText: "로그인 하기", + cancelText: "취소", + ctaAction: { [weak self] in + guard let self else { return } + let vc = self.loginFactory.make(exitRoute: .pop) + self.navigationController?.pushViewController(vc, animated: true) + }, + cancelAction: nil + ) + } + + private func updateCollectionView(items: [DictionaryMainItemResponse]) { + let collectionView = mainView.listCollectionView + mainView.checkEmptyData(isEmpty: items.isEmpty) + + guard let reactor = reactor else { return } + if reactor.currentState.currentPage == 0, !reactor.currentState.isBookmarkUpdateOnly { + collectionView.reloadData() + } else { + let startIndex = collectionView.numberOfItems(inSection: 0) + let endIndex = items.count + if endIndex > startIndex { + let indexPaths = (startIndex ..< endIndex).map { IndexPath(item: $0, section: 0) } + collectionView.performBatchUpdates { + collectionView.insertItems(at: indexPaths) } } - .disposed(by: disposeBag) + + for cell in collectionView.visibleCells { + if let indexPath = collectionView.indexPath(for: cell), + indexPath.item < items.count, + let cell = cell as? DictionaryListCell { + let item = items[indexPath.item] + cell.updateBookmarkState(isBookmarked: item.bookmarkId != nil) + } + } + } } } @@ -252,70 +370,13 @@ extension DictionaryListViewController: UICollectionViewDelegate, UICollectionVi indexPath: indexPath, collectionView: collectionView, isMap: item.type == .map, - onBookmarkTapped: { [weak self] isSelected in - guard let self = self else { return } - + onBookmarkTapped: { [weak self] in + guard let self else { return } guard state.isLogin else { - GuideAlertFactory.show( - mainText: "북마크를 하려면 로그인이 필요해요.", - ctaText: "로그인 하기", - cancelText: "취소", - ctaAction: { - let vc = self.loginFactory.make(exitRoute: .pop) - self.navigationController?.pushViewController(vc, animated: true) - }, - cancelAction: nil - ) + self.reactor?.action.onNext(.showLogin) return } - - self.reactor?.action.onNext(.toggleBookmark(id: item.id, isSelected: isSelected)) - - if isSelected { - SnackBarFactory.createSnackBar( - type: .delete, - imageUrl: item.imageUrl, - imageBackgroundColor: item.type.backgroundColor, - text: "아이템을 북마크에서 삭제했어요.", - buttonText: "되돌리기", - buttonAction: { [weak self] in - self?.reactor?.action.onNext(.undoLastDeletedBookmark) - } - ) - } else { - SnackBarFactory.createSnackBar( - type: .normal, - imageUrl: item.imageUrl, - imageBackgroundColor: item.type.backgroundColor, - text: "아이템을 북마크에 추가했어요.", - buttonText: "컬렉션 추가", - buttonAction: { - self.reactor?.state.map(\.listItems) - .compactMap { list in - list.first(where: { $0.id == item.id })?.bookmarkId - } - .take(1) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { bookmarkId in - let vc = self.bookmarkModalFactory.make(bookmarkIds: [bookmarkId]) { isAdd in - if isAdd { - ToastFactory.createToast( - message: "컬렉션에 추가되었어요. 북마크 탭에서 확인 할 수 있어요." - ) - } - } - vc.modalPresentationStyle = .pageSheet - if let sheet = vc.sheetPresentationController { - sheet.detents = [.medium(), .large()] - sheet.prefersGrabberVisible = true - sheet.preferredCornerRadius = 16 - } - self.present(vc, animated: true) - }) - .disposed(by: self.disposeBag) - } - ) - } + self.reactor?.action.onNext(.toggleBookmark(id: item.id)) } ) @@ -332,11 +393,11 @@ extension DictionaryListViewController: UICollectionViewDelegate, UICollectionVi switch reactor.currentState.type { case .total: guard let type = item.type.toDictionaryType else { return } - viewController = detailFactory.make(type: type, id: item.id, bookmarkRelay: bookmarkChangeRelay) + viewController = detailFactory.make(type: type, id: item.id, bookmarkRelay: bookmarkChangeRelay, loginRelay: loginRelay) default: // 단일 타입일 경우 리액터 타입에 따라 처리 viewController = detailFactory.make( - type: reactor.currentState.type, id: item.id, bookmarkRelay: bookmarkChangeRelay + type: reactor.currentState.type, id: item.id, bookmarkRelay: bookmarkChangeRelay, loginRelay: loginRelay ) } navigationController?.pushViewController(viewController, animated: true) diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryMain/DictionaryMainViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryMain/DictionaryMainViewController.swift index c4c1a01c..c7abe68b 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryMain/DictionaryMainViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryMain/DictionaryMainViewController.swift @@ -17,7 +17,6 @@ public final class DictionaryMainViewController: BaseViewController, View, Dicti public var disposeBag = DisposeBag() private let initialIndex: Int -// private lazy var currentPageIndex = BehaviorRelay(value: initialIndex) private let searchFactory: DictionarySearchFactory private let notificationFactory: DictionaryNotificationFactory diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationFactoryImpl.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationFactoryImpl.swift index bfaa367c..2543eac8 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationFactoryImpl.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationFactoryImpl.swift @@ -8,15 +8,25 @@ public final class DictionaryNotificationFactoryImpl: DictionaryNotificationFact private let fetchAllAlarmUseCase: FetchAllAlarmUseCase private let fetchProfileUseCase: FetchProfileUseCase + private let checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase + private let setReadUseCase: SetReadUseCase - public init(notificationSettingFactory: NotificationSettingFactory, fetchAllAlarmUseCase: FetchAllAlarmUseCase, fetchProfileUseCase: FetchProfileUseCase) { + public init( + notificationSettingFactory: NotificationSettingFactory, + fetchAllAlarmUseCase: FetchAllAlarmUseCase, + fetchProfileUseCase: FetchProfileUseCase, + checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase, + setReadUseCase: SetReadUseCase + ) { self.notificationSettingFactory = notificationSettingFactory self.fetchAllAlarmUseCase = fetchAllAlarmUseCase self.fetchProfileUseCase = fetchProfileUseCase + self.checkNotificationPermissionUseCase = checkNotificationPermissionUseCase + self.setReadUseCase = setReadUseCase } public func make() -> BaseViewController { - let reactor = DictionaryNotificationReactor(fetchAllAlarmUseCase: fetchAllAlarmUseCase, fetchProfileUseCase: fetchProfileUseCase) + let reactor = DictionaryNotificationReactor(fetchAllAlarmUseCase: fetchAllAlarmUseCase, fetchProfileUseCase: fetchProfileUseCase, checkNotificationPermissionUseCase: checkNotificationPermissionUseCase, setReadUseCase: setReadUseCase) let viewController = DictionaryNotificationViewController(notificationSettingFactory: notificationSettingFactory) viewController.reactor = reactor return viewController diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift index 1e3e3653..305f8bf7 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationReactor.swift @@ -8,7 +8,7 @@ public final class DictionaryNotificationReactor: Reactor { case none case dismiss case setting - case notification(String) + case notification(url: String) } public enum Action { @@ -16,7 +16,7 @@ public final class DictionaryNotificationReactor: Reactor { case loadMore case backButtonTapped case settingButtonTapped - case notificationTapped(String) + case notificationTapped(index: Int) } public enum Mutation { @@ -24,6 +24,8 @@ public final class DictionaryNotificationReactor: Reactor { case setLoading(Bool) case setProfile(MyPageResponse?) case navigateTo(Route) + case setPermission(Bool) + case checkAlarm(link: String) } public struct State { @@ -32,6 +34,7 @@ public final class DictionaryNotificationReactor: Reactor { var profile: MyPageResponse? var hasMore: Bool = false var isLoading: Bool = false + var permission = false } // MARK: - Properties @@ -39,47 +42,69 @@ public final class DictionaryNotificationReactor: Reactor { private let disposeBag = DisposeBag() private let fetchAllAlarmUseCase: FetchAllAlarmUseCase private let fetchProfileUseCase: FetchProfileUseCase + private let checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase + private let setReadUseCase: SetReadUseCase // MARK: - Init - public init(fetchAllAlarmUseCase: FetchAllAlarmUseCase, fetchProfileUseCase: FetchProfileUseCase) { + public init(fetchAllAlarmUseCase: FetchAllAlarmUseCase, fetchProfileUseCase: FetchProfileUseCase, checkNotificationPermissionUseCase: CheckNotificationPermissionUseCase, setReadUseCase: SetReadUseCase) { self.initialState = State() self.fetchAllAlarmUseCase = fetchAllAlarmUseCase self.fetchProfileUseCase = fetchProfileUseCase + self.checkNotificationPermissionUseCase = checkNotificationPermissionUseCase + self.setReadUseCase = setReadUseCase } // MARK: - Mutate public func mutate(action: Action) -> Observable { switch action { case .viewWillAppear: - return .concat([ - fetchProfileUseCase.execute() - .map { .setProfile($0) }, - .just(.setLoading(true)), + let profileStream: Observable = fetchProfileUseCase.execute() + .map { Mutation.setProfile($0) } + + let notificationStream: Observable = Observable.concat([ + Observable.just(.setLoading(true)), fetchAllAlarmUseCase.execute(cursor: nil, pageSize: 20) .map { paged in - .setNotifications(paged.items, hasMore: paged.hasMore, reset: true) + Mutation.setNotifications(paged.items, hasMore: paged.hasMore, reset: true) }, - .just(.setLoading(false)) + Observable.just(.setLoading(false)) ]) + + let permissionStream: Observable = checkNotificationPermissionUseCase.execute() + .asObservable() + .map { Mutation.setPermission($0) } + + return Observable.merge(profileStream, notificationStream, permissionStream) + case .loadMore: guard currentState.hasMore, !currentState.isLoading else { return .empty() } let cursor = currentState.notifications.last?.date - return .concat([ - .just(.setLoading(true)), + return Observable.concat([ + Observable.just(.setLoading(true)), fetchAllAlarmUseCase.execute(cursor: cursor, pageSize: 20) .map { paged in - .setNotifications(paged.items, hasMore: paged.hasMore, reset: false) + Mutation.setNotifications(paged.items, hasMore: paged.hasMore, reset: false) }, - .just(.setLoading(false)) + Observable.just(.setLoading(false)) ]) case .backButtonTapped: return .just(.navigateTo(.dismiss)) + case .settingButtonTapped: return .just(.navigateTo(.setting)) - case .notificationTapped(let notification): - return .just(.navigateTo(.notification(notification))) + + case let .notificationTapped(index): + let notification = currentState.notifications[index] + + return setReadUseCase.execute(alarmLink: notification.link) + .andThen( + Observable.concat([ + .just(.checkAlarm(link: notification.link)), + .just(.navigateTo(.notification(url: notification.link))) + ]) + ) } } @@ -104,6 +129,13 @@ public final class DictionaryNotificationReactor: Reactor { case let .navigateTo(route): newState.route = route + + case let .setPermission(granted): + newState.permission = granted + case let .checkAlarm(link): + if let index = newState.notifications.firstIndex(where: { $0.link == link }) { + newState.notifications[index].alreadyRead = true + } } return newState diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationView.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationView.swift index 2788458f..49602afe 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationView.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationView.swift @@ -83,9 +83,9 @@ private extension DictionaryNotificationView { } public extension DictionaryNotificationView { - func setEmpty(isEmpty: Bool) { - emptyView.isHidden = !isEmpty - titleLabel.isHidden = isEmpty - notificationCollectionView.isHidden = isEmpty + func setEmpty(hasPermission: Bool) { + emptyView.isHidden = hasPermission + titleLabel.isHidden = !hasPermission + notificationCollectionView.isHidden = !hasPermission } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationViewController.swift index 0a85b474..9161ff32 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionaryNotification/DictionaryNotificationViewController.swift @@ -58,7 +58,6 @@ private extension DictionaryNotificationViewController { func configureUI() { isBottomTabbarHidden = true - guard let reactor = reactor else { return } mainView.notificationCollectionView.delegate = self mainView.notificationCollectionView.dataSource = self @@ -103,6 +102,7 @@ public extension DictionaryNotificationViewController { .take(1) .flatMapLatest { _ in reactor.pulse(\.$route) } .withUnretained(self) + .observe(on: MainScheduler.instance) .subscribe { owner, route in switch route { case .dismiss: @@ -112,6 +112,9 @@ public extension DictionaryNotificationViewController { let profile = reactor.currentState.profile else { return } let viewController = owner.notificationSettingFactory.make(isAgreeEventNotification: profile.eventAgreement, isAgreeNoticeNotification: profile.noticeAgreement, isAgreePatchNoteNotification: profile.patchNoteAgreement) owner.navigationController?.pushViewController(viewController, animated: true) + case let .notification(url): + let webViewController = WebViewController(urlString: url) + owner.present(webViewController, animated: true) default: break } @@ -128,13 +131,12 @@ public extension DictionaryNotificationViewController { .disposed(by: disposeBag) reactor.state - .compactMap { $0.profile } + .map { $0.permission } .distinctUntilChanged() .withUnretained(self) .observe(on: MainScheduler.instance) - .subscribe { owner, profile in - let isEmpty = profile.noticeAgreement == false && profile.eventAgreement == false && profile.patchNoteAgreement == false - owner.mainView.setEmpty(isEmpty: isEmpty) + .subscribe { owner, permission in + owner.mainView.setEmpty(hasPermission: permission) } .disposed(by: disposeBag) } @@ -151,10 +153,15 @@ extension DictionaryNotificationViewController: UICollectionViewDelegate, UIColl guard let reactor = reactor else { return UICollectionViewCell() } guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DictionaryNotificationCell.identifier, for: indexPath) as? DictionaryNotificationCell else { return UICollectionViewCell() } let item = reactor.currentState.notifications[indexPath.row] - cell.inject(input: DictionaryNotificationCell.Input(title: item.title, subTitle: item.date.changeKoreanDate(), isChecked: item.alreadyRead)) + cell.inject(input: DictionaryNotificationCell.Input(title: item.title, subTitle: item.date.toDisplayDateString(), isChecked: item.alreadyRead)) return cell } + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let reactor = reactor else { return } + reactor.action.onNext(.notificationTapped(index: indexPath.row)) + } + public func scrollViewDidScroll(_ scrollView: UIScrollView) { let now = Date() guard now.timeIntervalSince(lastPagingTime) > 0.5 else { return } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearch/DictionarySearchViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearch/DictionarySearchViewController.swift index 49dbaf8f..a66f62ad 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearch/DictionarySearchViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearch/DictionarySearchViewController.swift @@ -117,7 +117,6 @@ extension DictionarySearchViewController { func bindUserActions(reactor: Reactor) { rx.viewWillAppear - .take(1) .map { Reactor.Action.viewWillAppear } .bind(to: reactor.action) .disposed(by: disposeBag) diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift index 5ebe6e86..eb07655e 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultFactoryImpl.swift @@ -6,14 +6,17 @@ public final class DictionarySearchResultFactoryImpl: DictionarySearchResultFact private let dictionaryListCountUseCase: FetchDictionaryListCountUseCase private let dictionaryMainListFactory: DictionaryMainListFactory private let dictionarySearchListUseCase: FetchDictionarySearchListUseCase - public init(dictionaryListCountUseCase: FetchDictionaryListCountUseCase, dictionaryMainListFactory: DictionaryMainListFactory, dictionarySearchListUseCase: FetchDictionarySearchListUseCase) { + private let recentSearchAddUseCase: RecentSearchAddUseCase + + public init(dictionaryListCountUseCase: FetchDictionaryListCountUseCase, dictionaryMainListFactory: DictionaryMainListFactory, dictionarySearchListUseCase: FetchDictionarySearchListUseCase, recentSearchAddUseCase: RecentSearchAddUseCase) { self.dictionaryListCountUseCase = dictionaryListCountUseCase self.dictionaryMainListFactory = dictionaryMainListFactory self.dictionarySearchListUseCase = dictionarySearchListUseCase + self.recentSearchAddUseCase = recentSearchAddUseCase } public func make(keyword: String?) -> BaseViewController { - let reactor = DictionarySearchResultReactor(keyword: keyword, dictionarySearchUseCase: dictionarySearchListUseCase, dictionarySearchCountUseCase: dictionaryListCountUseCase) + let reactor = DictionarySearchResultReactor(keyword: keyword, dictionarySearchUseCase: dictionarySearchListUseCase, dictionarySearchCountUseCase: dictionaryListCountUseCase, recentSearchAddUseCase: recentSearchAddUseCase) let viewController = DictionarySearchResultViewController(dictionaryListFactory: dictionaryMainListFactory, reactor: reactor) return viewController } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultReactor.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultReactor.swift index 6e3dc4a0..70019158 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultReactor.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultReactor.swift @@ -41,12 +41,14 @@ public final class DictionarySearchResultReactor: Reactor { // MARK: - UseCases private let dictionarySearchUseCase: FetchDictionarySearchListUseCase private let dictionarySearchCountUseCase: FetchDictionaryListCountUseCase + private let recentSearchAddUseCase: RecentSearchAddUseCase // MARK: - init - public init(keyword: String?, dictionarySearchUseCase: FetchDictionarySearchListUseCase, dictionarySearchCountUseCase: FetchDictionaryListCountUseCase) { + public init(keyword: String?, dictionarySearchUseCase: FetchDictionarySearchListUseCase, dictionarySearchCountUseCase: FetchDictionaryListCountUseCase, recentSearchAddUseCase: RecentSearchAddUseCase) { self.initialState = State(keyword: keyword) self.dictionarySearchUseCase = dictionarySearchUseCase self.dictionarySearchCountUseCase = dictionarySearchCountUseCase + self.recentSearchAddUseCase = recentSearchAddUseCase } // MARK: - Reactor Methods @@ -67,8 +69,8 @@ public final class DictionarySearchResultReactor: Reactor { // 검색 결과 화면에서 재검색 시 case .searchButtonTapped(let keyword): let keyword = keyword ?? "" - - return Observable.just(.setKeyword(keyword)) + return recentSearchAddUseCase.add(keyword: keyword) + .andThen(.just(.setKeyword(keyword))) } } diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultViewController.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultViewController.swift index 0fba890e..d04818a4 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultViewController.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeature/DictionarySearchResult/DictionarySearchResultViewController.swift @@ -1,6 +1,7 @@ import UIKit import BaseFeature +import DesignSystem import DictionaryFeatureInterface import DomainInterface @@ -44,7 +45,6 @@ public final class DictionarySearchResultViewController: BaseViewController, Vie @MainActor required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - } // MARK: - Life Cycle @@ -54,8 +54,8 @@ public extension DictionarySearchResultViewController { addViews() setupConstraints() configureUI() - } + override func viewDidAppear(_ animated: Bool) { guard !didSetInitialIndex else { return } didSetInitialIndex = true @@ -73,7 +73,7 @@ public extension DictionarySearchResultViewController { } // 새로운 viewControllers 생성 - self.viewControllers = type.pageTabList.map { dictionaryListFactory.make(type: $0, listType: type, keyword: keyword) } + viewControllers = type.pageTabList.map { dictionaryListFactory.make(type: $0, listType: type, keyword: keyword) } // PageViewController에 첫 번째 뷰컨트롤러 설정 if !viewControllers.isEmpty { @@ -99,7 +99,6 @@ public extension DictionarySearchResultViewController { } } } - } // MARK: - SetUp @@ -118,10 +117,14 @@ private extension DictionarySearchResultViewController { } func configureUI() { + mainView.searchBar.searchDelegate = self + mainView.searchBar.textField.becomeFirstResponder() + mainView.pageViewController.delegate = self mainView.pageViewController.dataSource = self configureTabCollectionView() isBottomTabbarHidden = true + mainView.searchBar.textField.resignFirstResponder() } func configureTabCollectionView() { @@ -197,17 +200,23 @@ public extension DictionarySearchResultViewController { .disposed(by: disposeBag) reactor.state - .compactMap { $0.keyword } + .map(\.keyword) + .filter { $0 != nil } + .map { $0! } .distinctUntilChanged() .skip(1) .observe(on: MainScheduler.instance) .bind(with: self) { owner, newKeyword in - owner.updateViewControllers(keyword: newKeyword) + if newKeyword.isOnlyKorean(), newKeyword != "" { + GuideAlertFactory.show(mainText: "초성은 검색할 수 없습니다.", ctaText: "확인", ctaAction: {}) + } else { + owner.updateViewControllers(keyword: newKeyword) + } } .disposed(by: disposeBag) rx.viewWillAppear - .map {_ in Reactor.Action.viewWillAppear } + .map { _ in Reactor.Action.viewWillAppear } .bind(to: reactor.action) .disposed(by: disposeBag) @@ -284,3 +293,9 @@ extension DictionarySearchResultViewController: UICollectionViewDataSource, UICo underLineController.animateIndicatorToSelectedItem() } } + +extension DictionarySearchResultViewController: SearchBarDelegate { + public func searchBarDidReturn(_ searchBar: SearchBar, text: String) { + reactor?.action.onNext(.searchButtonTapped(text)) + } +} diff --git a/MLS/Presentation/DictionaryFeature/DictionaryFeatureInterface/DictionaryDetailFactory.swift b/MLS/Presentation/DictionaryFeature/DictionaryFeatureInterface/DictionaryDetailFactory.swift index 4844a946..562bfe32 100644 --- a/MLS/Presentation/DictionaryFeature/DictionaryFeatureInterface/DictionaryDetailFactory.swift +++ b/MLS/Presentation/DictionaryFeature/DictionaryFeatureInterface/DictionaryDetailFactory.swift @@ -4,5 +4,5 @@ import DomainInterface import RxCocoa public protocol DictionaryDetailFactory { - func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(Int, Bool)>?) -> BaseViewController + func make(type: DictionaryType, id: Int, bookmarkRelay: PublishRelay<(id: Int, newBookmarkId: Int?)>?, loginRelay: PublishRelay?) -> BaseViewController } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift index a759a41c..4261d67c 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Announcement/AnnouncementViewController.swift @@ -22,6 +22,10 @@ final class AnnouncementViewController: CustomerSupportBaseViewController, View onItemTapped = { [weak self] itemIndex in self?.reactor?.action.onNext(.itemTapped(itemIndex)) } + + onLoadMore = { [weak self] in + self?.reactor?.action.onNext(.loadMore) + } } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseView.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseView.swift index 5ff7b1a1..9ba80e06 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseView.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseView.swift @@ -18,6 +18,8 @@ final class CustomerSupportBaseView: UIView { static let textWidth: CGFloat = 300 static let viewHeight: CGFloat = 86 static let dateTopMargin: CGFloat = 4 + static let emptyLabelHeight: CGFloat = 86 + static let emptyLabelInset: CGFloat = 16 static let menuTabBarButtonInset: NSDirectionalEdgeInsets = .init(top: 9, leading: 4, bottom: 9, trailing: 4) static let menuTabBarHorizontalInset: UIEdgeInsets = .init(top: 0, left: 16, bottom: 0, right: 16) @@ -63,11 +65,13 @@ final class CustomerSupportBaseView: UIView { stackView.layoutMargins = Constant.menuTabBarHorizontalInset return stackView }() + let bottomLineView: UIView = { let view = UIView() view.backgroundColor = .neutral300 return view }() + // 메뉴 스택뷰 왼쪽 정렬을 위해서 let emptyView = UIView() @@ -76,6 +80,7 @@ final class CustomerSupportBaseView: UIView { let scrollView = UIScrollView() return scrollView }() + // 아이템 담을 스택 뷰 let detailItemStackView: UIStackView = { let stackView = UIStackView() @@ -102,7 +107,6 @@ final class CustomerSupportBaseView: UIView { // MARK: - SetUp private extension CustomerSupportBaseView { - func addViews() { [backButton, titleLabel].forEach { headerView.addSubview($0) } [headerView, menuContainerView, scrollView].forEach { addSubview($0) } @@ -133,7 +137,6 @@ private extension CustomerSupportBaseView { make.top.equalTo(headerView.snp.bottom).offset(Constant.topMargin) make.width.equalToSuperview() make.height.equalTo(Constant.menuStackViewHeight) - } menuStackView.snp.makeConstraints { make in make.width.equalToSuperview() @@ -161,6 +164,7 @@ private extension CustomerSupportBaseView { } } } + extension CustomerSupportBaseView { func createMenuButton(title: String, tag: Int) -> UIButton { let config = setupConfig() @@ -211,7 +215,7 @@ extension CustomerSupportBaseView { view.snp.makeConstraints { make in make.height.equalTo(Constant.viewHeight) } - if let dateText = dateText { + if dateText != nil { title.snp.makeConstraints { make in make.top.equalToSuperview().inset(Constant.topMargin) // 임의 너비 설정 @@ -257,8 +261,26 @@ extension CustomerSupportBaseView { } setMenuHidden(true) // menuContainer뷰 숨기기 } + // menuContainerView Encapsulation func setMenuHidden(_ hidden: Bool) { - menuContainerView.isHidden = hidden - } + menuContainerView.isHidden = hidden + } + + func setEmpty(text: String) { + let label = UILabel() + label.attributedText = .makeStyledString(font: .b_m_r, text: text, color: .neutral700, alignment: .left) + let view = UIView() + + view.addSubview(label) + + view.snp.makeConstraints { make in + make.height.equalTo(Constant.emptyLabelHeight) + } + label.snp.makeConstraints { make in + make.horizontalEdges.equalToSuperview().inset(Constant.emptyLabelInset) + make.centerY.equalToSuperview() + } + detailItemStackView.addArrangedSubview(view) + } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewController.swift index 1f3c19ef..4649e4f5 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewController.swift @@ -19,6 +19,7 @@ class CustomerSupportBaseViewController: BaseViewController { public var currentTabIndex: Int? public var urlStrings: [String] = [] var onItemTapped: ((Int) -> Void)? + var onLoadMore: (() -> Void)? private let policyFactory: PolicyFactory? @@ -51,11 +52,12 @@ class CustomerSupportBaseViewController: BaseViewController { addViews() setupConstaraints() bindBackButton() + bindScrollPagination() } func createDetailItem(items: [AlarmResponse]) { for (index, item) in items.enumerated() { - let view = mainView.createDetailItem(titleText: item.title, dateText: item.date.changeKoreanDate()) + let view = mainView.createDetailItem(titleText: item.title, dateText: item.date.toDisplayDateString()) view.tag = index urlStrings.append(item.link) @@ -137,4 +139,22 @@ extension CustomerSupportBaseViewController { } .disposed(by: disposeBag) } + + func bindScrollPagination() { + mainView.scrollView.rx.contentOffset + .throttle(.milliseconds(300), scheduler: MainScheduler.instance) + .map { [weak self] offset -> Bool in + guard let self = self else { return false } + let contentHeight = self.mainView.scrollView.contentSize.height + let height = self.mainView.scrollView.frame.size.height + let offsetY = offset.y + return offsetY > contentHeight - height - 100 + } + .distinctUntilChanged() + .filter { $0 } + .bind { [weak self] _ in + self?.onLoadMore?() + } + .disposed(by: disposeBag) + } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift index 2a09be91..3507e671 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/CustomerSupportBaseViewFactoryImpl.swift @@ -48,9 +48,6 @@ public final class CustomerSupportBaseViewFactoryImpl: CustomerSupportFactory { } case .terms: viewController = TermsViewController(type: .terms, policyFactory: policyFactory) - if let viewController = viewController as? TermsViewController { - - } } viewController.isBottomTabbarHidden = true diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift index 41e842cf..654c0dab 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventReactor.swift @@ -44,12 +44,21 @@ public final class EventReactor: Reactor { public func mutate(action: Action) -> Observable { switch action { case let .selectTab(index): + let fetchObservable = (index == 0 + ? fetchOngoingEventsUseCase.execute(cursor: nil, pageSize: 20) + : fetchOutdatedEventsUseCase.execute(cursor: nil, pageSize: 20)) + .map { paged -> Mutation in + .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) + } + .catch { error -> Observable in + print("Fetch error: \(error)") + return .just(.setLoading(false)) + } + return .concat([ .just(.setIndex(index)), - .just(.setLoading(true)), (index == 0 ? fetchOngoingEventsUseCase.execute(cursor: nil, pageSize: 20) : fetchOutdatedEventsUseCase.execute(cursor: nil, pageSize: 20)) - .map { paged in - .setAlarms(paged.items, hasMore: paged.hasMore, reset: true) - }, + .just(.setLoading(true)), + fetchObservable, .just(.setLoading(false)) ]) @@ -65,7 +74,7 @@ public final class EventReactor: Reactor { }, .just(.setLoading(false)) ]) - case .itemTapped(let index): + case let .itemTapped(index): return setReadUseCase.execute(alarmLink: currentState.alarms[index].link) .andThen(.empty()) } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift index 1a842c98..f49ec0cb 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Event/EventViewController.swift @@ -17,6 +17,10 @@ final class EventViewController: CustomerSupportBaseViewController, View { onItemTapped = { [weak self] itemIndex in self?.reactor?.action.onNext(.itemTapped(itemIndex)) } + + onLoadMore = { [weak self] in + self?.reactor?.action.onNext(.loadMore) + } } // MARK: - Setup @@ -66,7 +70,12 @@ extension EventViewController { self.mainView.detailItemStackView.removeArrangedSubview(subview) subview.removeFromSuperview() } - self.createDetailItem(items: items) + let eventType = reactor.currentState.selectedIndex == 0 ? "진행중인" : "종료된" + if items.isEmpty { + self.mainView.setEmpty(text: "\(eventType) 이벤트가 없습니다.") + } else { + self.createDetailItem(items: items) + } } .disposed(by: disposeBag) } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift index b896c8b4..56397f22 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/PatchNote/PatchNoteViewController.swift @@ -22,6 +22,10 @@ final class PatchNoteViewController: CustomerSupportBaseViewController, View { onItemTapped = { [weak self] itemIndex in self?.reactor?.action.onNext(.itemTapped(itemIndex)) } + + onLoadMore = { [weak self] in + self?.reactor?.action.onNext(.loadMore) + } } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Policy/PolicyView.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Policy/PolicyView.swift index 743a3894..df7f7fba 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Policy/PolicyView.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/CustomerSupport/Policy/PolicyView.swift @@ -15,7 +15,13 @@ final class PolicyView: UIView { private let titleLabel = UILabel() - private let contentTextView = UITextView() + private let contentTextView: UITextView = { + let view = UITextView() + view.isScrollEnabled = true + view.isEditable = false + view.isSelectable = false + return view + }() // MARK: - Init init(type: PolicyType) { @@ -61,6 +67,20 @@ private extension PolicyView { headerView.addButton.isHidden = true headerView.setTitle(title: type.title) titleLabel.attributedText = .makeStyledString(font: .h_xxxl_sb, text: "메랜사 \(type.title)", alignment: .left) - contentTextView.attributedText = .makeStyledString(font: .b_s_r, text: type.content, alignment: .left) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineBreakMode = .byWordWrapping + paragraphStyle.alignment = .left + + let attrString = NSAttributedString( + string: type.content, + attributes: [ + .font: UIFont.b_s_r ?? .systemFont(ofSize: 12), + .foregroundColor: UIColor.textColor, + .paragraphStyle: paragraphStyle + ] + ) + + contentTextView.attributedText = attrString } } diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileView.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileView.swift index 95503d80..07655712 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileView.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileView.swift @@ -248,9 +248,9 @@ public final class SetProfileView: UIView { fatalError("init(coder:) has not been implemented") } - public override var inputAccessoryView: UIView? { - return errorMessageContentView - } +// public override var inputAccessoryView: UIView? { +// return errorMessageContentView +// } public override var canBecomeFirstResponder: Bool { return true @@ -316,6 +316,7 @@ private extension SetProfileView { func configureUI() { backgroundColor = .whiteMLS cancelTextView.delegate = self + nickNameInputBox.textField.inputAccessoryView = errorMessageContentView } func bindImageGesture() { diff --git a/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileViewController.swift b/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileViewController.swift index 5edac989..fd8264d2 100644 --- a/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileViewController.swift +++ b/MLS/Presentation/MyPageFeature/MyPageFeature/SetProfile/SetProfileViewController.swift @@ -138,6 +138,7 @@ extension SetProfileViewController { .bind(onNext: { owner, profile in owner.mainView.setImage(imageUrl: profile.profileUrl) owner.mainView.setPlatform(platform: profile.platform) + owner.mainView.nickNameInputBox.textField.text = profile.nickname }) .disposed(by: disposeBag)