diff --git a/MOUP/MOUP/App/AppCoordinator.swift b/MOUP/MOUP/App/AppCoordinator.swift index 33349dc3..5b486ff7 100644 --- a/MOUP/MOUP/App/AppCoordinator.swift +++ b/MOUP/MOUP/App/AppCoordinator.swift @@ -117,7 +117,15 @@ final class AppCoordinator: Coordinator { switch destination { case .notificationList: - moveToNotificationList() + let type = userInfo[PushNotificationKey.type] as? String + let workerId = userInfo[PushNotificationKey.workerId] as? Int + let workplaceId = userInfo[PushNotificationKey.workplaceId] as? Int + + moveToNotificationList( + pushType: type, + pushWorkerId: workerId, + pushWorkplaceId: workplaceId + ) case .routineDetail: break case .workDetail: @@ -152,13 +160,23 @@ final class AppCoordinator: Coordinator { tabBarCoordinator.start() } - private func moveToNotificationList() { + private func moveToNotificationList( + pushType: String? = nil, + pushWorkerId: Int? = nil, + pushWorkplaceId: Int? = nil, + pushNotificationId: Int? = nil + ) { guard let tabBarCoordinator = childCoordinators.first(where: { $0 is TabBarCoordinator }) as? TabBarCoordinator else { self.logger.error("TabBarCoordinator를 찾을 수 없습니다.") return } - - tabBarCoordinator.moveToNotificationList() + + tabBarCoordinator.moveToNotificationList( + pushType: pushType, + pushWorkerId: pushWorkerId, + pushWorkplaceId: pushWorkplaceId, + pushNotificationId: pushNotificationId + ) self.logger.debug("알림 리스트로 이동 완료") } diff --git a/MOUP/MOUP/App/AppDelegate.swift b/MOUP/MOUP/App/AppDelegate.swift index 8f412b5d..05f68725 100644 --- a/MOUP/MOUP/App/AppDelegate.swift +++ b/MOUP/MOUP/App/AppDelegate.swift @@ -103,19 +103,20 @@ extension AppDelegate: UNUserNotificationCenterDelegate { private func handleNotificationTap(_ userInfo: [AnyHashable: Any]) { let notificationTypeString = userInfo[PushNotificationKey.type] as? String - let notificationType = PushNotificationType(from: notificationTypeString) - - if let notificationType = notificationType { - self.logger.debug("알림 타입: \(notificationType.rawValue)") - } else { - self.logger.warning("알림 타입이 없거나 알 수 없는 타입입니다: \(notificationTypeString ?? "nil")") - } - - let destination = determineDestination(for: notificationType) + let destination = determineDestination( + for: PushNotificationType(from: notificationTypeString) + ) + let workerIdString = userInfo[PushNotificationKey.workerId] as? String + let workplaceIdString = userInfo[PushNotificationKey.workplaceId] as? String + let notificationIdString = userInfo[PushNotificationKey.notificationId] as? String + postNotificationTappedEvent( destination: destination, - type: notificationTypeString + type: notificationTypeString, + workerId: workerIdString.flatMap { Int($0) }, + workplaceId: workplaceIdString.flatMap { Int($0) }, + notificationId: notificationIdString.flatMap { Int($0) } ) } @@ -134,16 +135,31 @@ extension AppDelegate: UNUserNotificationCenterDelegate { private func postNotificationTappedEvent( destination: PushNotificationDestination, - type: String? + type: String?, + workerId: Int?, + workplaceId: Int?, + notificationId: Int? ) { var userInfo: [String: Any] = [ PushNotificationKey.destination: destination.rawValue ] - if let type = type { + if let type { userInfo[PushNotificationKey.type] = type } + if let workerId { + userInfo[PushNotificationKey.workerId] = workerId + } + + if let workplaceId { + userInfo[PushNotificationKey.workplaceId] = workplaceId + } + + if let notificationId { + userInfo[PushNotificationKey.notificationId] = notificationId + } + NotificationCenter.default.post( name: .pushNotificationTapped, object: nil, diff --git a/MOUP/MOUP/Common/Enums/PushNotification.swift b/MOUP/MOUP/Common/Enums/PushNotification.swift index b274d6d6..b5558f8b 100644 --- a/MOUP/MOUP/Common/Enums/PushNotification.swift +++ b/MOUP/MOUP/Common/Enums/PushNotification.swift @@ -21,7 +21,7 @@ enum PushNotificationDestination: String { enum PushNotificationType: String { case inviteApproved = "INVITE_APPROVED" case inviteRejected = "INVITE_REJECTED" - case inviteRequest = "INVITE_REQUEST" + case inviteRequest = "WORKPLACE_JOIN_REQUEST" case paydayReminder = "PAYDAY_REMINDER" init?(from string: String?) { @@ -33,4 +33,7 @@ enum PushNotificationType: String { enum PushNotificationKey { static let type = "type" static let destination = "destination" + static let workerId = "workerId" + static let workplaceId = "workplaceId" + static let notificationId = "notificationId" } diff --git a/MOUP/MOUP/Common/Utils/FCMTokenManager.swift b/MOUP/MOUP/Common/Utils/FCMTokenManager.swift index a8bc1faf..0ff4c0e4 100644 --- a/MOUP/MOUP/Common/Utils/FCMTokenManager.swift +++ b/MOUP/MOUP/Common/Utils/FCMTokenManager.swift @@ -33,6 +33,11 @@ final class FCMTokenManager { return } + guard KeychainManager.shared.read(key: "accessToken") != nil else { + logger.info("로그인 전이므로 FCM 토큰 동기화를 보류합니다.") + return + } + Task { do { try await authUseCase.updateFCMToken(token) diff --git a/MOUP/MOUP/Coordinator/HomeCoordinator.swift b/MOUP/MOUP/Coordinator/HomeCoordinator.swift index ef088f83..415e5e3e 100644 --- a/MOUP/MOUP/Coordinator/HomeCoordinator.swift +++ b/MOUP/MOUP/Coordinator/HomeCoordinator.swift @@ -26,6 +26,12 @@ final class HomeCoordinator: Coordinator { private let notificationRepository: NotificationRepositoryProtocol private let notificationUseCase: NotificationUseCaseProtocol private let draftRoutineStorage: DraftRoutineStorageProtocol + private lazy var notificationListViewModel: NotificationListViewModel = { + return NotificationListViewModel( + notificationUseCase: notificationUseCase, + workplaceUseCase: workplaceUseCase + ) + }() init(navigationController: UINavigationController, userRole: UserRole) { self.navigationController = navigationController @@ -193,9 +199,17 @@ final class HomeCoordinator: Coordinator { childCoordinators.removeAll { $0 === coordinator } } - func showNotificationList() { - let viewModel = NotificationListViewModel(notificationUseCase: notificationUseCase) - let notificationListVC = NotificationListViewController(viewModel: viewModel) + func showNotificationList( + pushType: String? = nil, + pushWorkerId: Int? = nil, + pushWorkplaceId: Int? = nil + ) { + let notificationListVC = NotificationListViewController( + viewModel: notificationListViewModel, + pushType: pushType, + pushWorkerId: pushWorkerId, + pushWorkplaceId: pushWorkplaceId + ) navigationController.pushViewController(notificationListVC, animated: true) } diff --git a/MOUP/MOUP/Coordinator/TabBarCoordinator.swift b/MOUP/MOUP/Coordinator/TabBarCoordinator.swift index 6e4f8352..ba1002fc 100644 --- a/MOUP/MOUP/Coordinator/TabBarCoordinator.swift +++ b/MOUP/MOUP/Coordinator/TabBarCoordinator.swift @@ -85,7 +85,12 @@ final class TabBarCoordinator: Coordinator { window.makeKeyAndVisible() } - func moveToNotificationList() { + func moveToNotificationList( + pushType: String? = nil, + pushWorkerId: Int? = nil, + pushWorkplaceId: Int? = nil, + pushNotificationId: Int? = nil + ) { tabBarController.selectedIndex = 0 guard let homeCoordinator = childCoordinators.first(where: { @@ -93,8 +98,14 @@ final class TabBarCoordinator: Coordinator { }) as? HomeCoordinator else { return } - - homeCoordinator.showNotificationList() + + DispatchQueue.main.async { + homeCoordinator.showNotificationList( + pushType: pushType, + pushWorkerId: pushWorkerId, + pushWorkplaceId: pushWorkplaceId + ) + } } } diff --git a/MOUP/MOUP/Data/DTO/Response/NotificationResponseDTOs.swift b/MOUP/MOUP/Data/DTO/Response/NotificationResponseDTOs.swift index 2ee1bea7..1c48e91b 100644 --- a/MOUP/MOUP/Data/DTO/Response/NotificationResponseDTOs.swift +++ b/MOUP/MOUP/Data/DTO/Response/NotificationResponseDTOs.swift @@ -7,7 +7,11 @@ import Foundation -struct NotificationResponseDTO: Decodable { +struct NotificationListResponseDTO: Decodable { + let notificationList: [NotificationItemDTO] +} + +struct NotificationItemDTO: Decodable { let id: Int let senderId: Int? let receiverId: Int? @@ -15,4 +19,43 @@ struct NotificationResponseDTO: Decodable { let content: String let sentAt: String let readAt: String? + + let type: String? + let workerId: Int? + let workplaceId: Int? + + func toDomain() -> UserNotification { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") + + let sentDate = dateFormatter.date(from: sentAt) ?? Date() + let readDate = readAt.flatMap { readAtString in + dateFormatter.date(from: readAtString) + } + + let notificationType = PushNotificationType(from: type) + + let metadata: NotificationMetadata? + if workerId != nil || workplaceId != nil { + metadata = NotificationMetadata( + workerId: workerId, + workplaceId: workplaceId + ) + } else { + metadata = nil + } + + return UserNotification( + id: id, + senderId: senderId, + receiverId: receiverId, + title: title, + content: content, + sentAt: sentDate, + readAt: readDate, + type: notificationType, + metadata: metadata + ) + } } diff --git a/MOUP/MOUP/Data/Error/NetworkError.swift b/MOUP/MOUP/Data/Error/NetworkError.swift index 232cadb0..00f0d7fc 100644 --- a/MOUP/MOUP/Data/Error/NetworkError.swift +++ b/MOUP/MOUP/Data/Error/NetworkError.swift @@ -12,6 +12,11 @@ enum NetworkError: LocalizedError { case noResponse case invalidResponse(Error) // 정해진 에러 외 케이스 case decodeError // 디코딩이 원활하지 않았을 때 + case badRequest // 400 + case forbidden // 403 + case notFound // 404 + case invalidField // 422 + case unknown // 기타 } extension NetworkError { @@ -20,7 +25,7 @@ extension NetworkError { switch self { case .serverError: "현재 서버가 불안정합니다. 잠시 후 다시 시도해주세요." - case .noResponse, .invalidResponse, .decodeError: + case .noResponse, .invalidResponse, .decodeError, .badRequest, .forbidden, .notFound, .invalidField, .unknown: "예상치 못한 결과가 발생했습니다. 잠시 후 다시 시도해주세요." // TODO: - 사용자 친화 멘트 생각해봐야 함. } } @@ -36,6 +41,16 @@ extension NetworkError { "invalidResponse: \(error.localizedDescription)" case .decodeError: "디코딩 과정 문제 발생" + case .badRequest: + "잘못된 요청입니다." + case .forbidden: + "권한이 없습니다." + case .notFound: + "요청한 정보를 찾을 수 없습니다." + case .invalidField: + "유효하지 않은 값입니다." + case .unknown: + "알 수 없는 오류가 발생했습니다." } } } diff --git a/MOUP/MOUP/Data/Repositories/AuthRepository.swift b/MOUP/MOUP/Data/Repositories/AuthRepository.swift index 533c2453..129b5133 100644 --- a/MOUP/MOUP/Data/Repositories/AuthRepository.swift +++ b/MOUP/MOUP/Data/Repositories/AuthRepository.swift @@ -31,6 +31,8 @@ final class AuthRepository: AuthRepositoryProtocol { UserDefaultsManager.shared.userRole = role UserDefaultsManager.shared.hasSignIn = true + + FCMTokenManager.shared.syncTokenToServer() } func signUp(requestDTO: RegisterRequestDTO) async throws -> UserRole { diff --git a/MOUP/MOUP/Data/Repositories/NotificationRepository.swift b/MOUP/MOUP/Data/Repositories/NotificationRepository.swift index a4b74dee..9a8ca9f6 100644 --- a/MOUP/MOUP/Data/Repositories/NotificationRepository.swift +++ b/MOUP/MOUP/Data/Repositories/NotificationRepository.swift @@ -15,8 +15,8 @@ final class NotificationRepository: NotificationRepositoryProtocol { } func fetchNotifications() async throws -> [UserNotification] { - let dtos = try await notificationService.fetchNotifications() - return dtos.map { mapToEntity($0) } + let response = try await notificationService.fetchNotifications() + return response.notificationList.map { $0.toDomain() } } func markAsRead(id: Int) async throws { @@ -34,39 +34,4 @@ final class NotificationRepository: NotificationRepositoryProtocol { func deleteAllNotifications() async throws { try await notificationService.deleteAllNotifications() } - - private func mapToEntity(_ dto: NotificationResponseDTO) -> UserNotification { - let sentDate = parseDate(dto.sentAt) - let readDate = dto.readAt.flatMap { parseDate($0) } - - return UserNotification( - id: dto.id, - senderId: dto.senderId, - receiverId: dto.receiverId, - title: dto.title, - content: dto.content, - sentAt: sentDate, - readAt: readDate - ) - } - - private func parseDate(_ dateString: String) -> Date { - let formats = [ - "yyyy-MM-dd'T'HH:mm:ss", - "yyyy-MM-dd'T'HH:mm:ss.SSS", - "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - ] - - for format in formats { - let formatter = DateFormatter() - formatter.dateFormat = format - formatter.timeZone = TimeZone(identifier: "UTC") - - if let date = formatter.date(from: dateString) { - return date - } - } - - return Date() - } } diff --git a/MOUP/MOUP/Data/Repositories/WorkplaceRepository.swift b/MOUP/MOUP/Data/Repositories/WorkplaceRepository.swift index 84a24ac7..c6ea51e8 100644 --- a/MOUP/MOUP/Data/Repositories/WorkplaceRepository.swift +++ b/MOUP/MOUP/Data/Repositories/WorkplaceRepository.swift @@ -69,4 +69,17 @@ final class WorkplaceRepository: WorkplaceRepositoryProtocol { try await workplaceService.updateWorkplace(workplaceId: workplaceId, request: request) } + func approveJoinRequest(workplaceId: Int, workerId: Int) async throws { + try await workplaceService.approveJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + } + + func rejectJoinRequest(workplaceId: Int, workerId: Int) async throws { + try await workplaceService.rejectJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + } } diff --git a/MOUP/MOUP/Data/Routers/WorkplaceRouter.swift b/MOUP/MOUP/Data/Routers/WorkplaceRouter.swift index 99798251..32f89c32 100644 --- a/MOUP/MOUP/Data/Routers/WorkplaceRouter.swift +++ b/MOUP/MOUP/Data/Routers/WorkplaceRouter.swift @@ -18,6 +18,8 @@ enum WorkplaceRouter { case deleteWorkplace(workplaceId: Int) case fetchWorkplaceDetail(id: Int) case updateWorkplace(workplaceId: Int, request: UpdateWorkplaceRequestDTO) + case approveJoinRequest(workplaceId: Int, workerId: Int) + case rejectJoinRequest(workplaceId: Int, workerId: Int) } extension WorkplaceRouter: URLRequestConvertible { @@ -48,6 +50,9 @@ extension WorkplaceRouter: URLRequestConvertible { return "/workplaces/\(id)" case .updateWorkplace(let workplaceId, _): return "/workplaces/\(workplaceId)" + case .approveJoinRequest(let workplaceId, let workerId), + .rejectJoinRequest(let workplaceId, let workerId): + return "/workplaces/\(workplaceId)/workers/\(workerId)/accept" } } @@ -63,6 +68,10 @@ extension WorkplaceRouter: URLRequestConvertible { return .patch case .deleteWorkplace: return .delete + case .approveJoinRequest: + return .patch + case .rejectJoinRequest: + return .delete } } @@ -74,6 +83,9 @@ extension WorkplaceRouter: URLRequestConvertible { return ["view": "detail"] case .createWorkplace, .createOwnerWorkplace, .fetchWorkplaceByInviteCode, .fetchInviteCode, .joinWorkplace, .deleteWorkplace, .updateWorkplace: return nil + case .approveJoinRequest, + .rejectJoinRequest: + return nil } } @@ -81,6 +93,9 @@ extension WorkplaceRouter: URLRequestConvertible { switch self { case .fetchWorkplaceList, .fetchWorkplaceByInviteCode, .deleteWorkplace, .fetchWorkplaceDetail: return nil + case .approveJoinRequest, + .rejectJoinRequest: + return nil case .fetchInviteCode(_, let requestDTO): return requestDTO case .createWorkplace(let request): @@ -100,6 +115,9 @@ extension WorkplaceRouter: URLRequestConvertible { return URLEncoding.default case .createWorkplace, .createOwnerWorkplace, .fetchInviteCode, .joinWorkplace, .updateWorkplace: return JSONEncoding.default + case .approveJoinRequest, + .rejectJoinRequest: + return JSONEncoding.default } } diff --git a/MOUP/MOUP/Data/Services/NotificationService.swift b/MOUP/MOUP/Data/Services/NotificationService.swift index 5aa59d14..cc62d98b 100644 --- a/MOUP/MOUP/Data/Services/NotificationService.swift +++ b/MOUP/MOUP/Data/Services/NotificationService.swift @@ -9,7 +9,7 @@ import Foundation import Alamofire protocol NotificationServiceProtocol: AnyObject { - func fetchNotifications() async throws -> [NotificationResponseDTO] + func fetchNotifications() async throws -> NotificationListResponseDTO func markAsRead(id: Int) async throws func markAllAsRead() async throws func deleteNotification(id: Int) async throws @@ -19,9 +19,9 @@ protocol NotificationServiceProtocol: AnyObject { final class NotificationService: NotificationServiceProtocol { private lazy var session = NetworkManager.shared.session - func fetchNotifications() async throws -> [NotificationResponseDTO] { + func fetchNotifications() async throws -> NotificationListResponseDTO { let request = session.request(NotificationRouter.fetchNotifications) - let response = await request.serializingDecodable([NotificationResponseDTO].self).response + let response = await request.serializingDecodable([NotificationItemDTO].self).response guard let statusCode = response.response?.statusCode else { throw NetworkError.noResponse @@ -29,16 +29,16 @@ final class NotificationService: NotificationServiceProtocol { switch statusCode { case 200: - guard let dtos = response.value else { + guard let dtoArray = response.value else { throw NetworkError.noResponse } - return dtos + return NotificationListResponseDTO(notificationList: dtoArray) case 401: print("알림 조회 실패: 인증 실패") throw NetworkError.serverError case 404: print("알림 조회 실패: 조회 결과 없음") - return [] + return NotificationListResponseDTO(notificationList: []) default: print(NetworkError.serverError.debugDescription!) throw NetworkError.serverError diff --git a/MOUP/MOUP/Data/Services/WorkplaceService.swift b/MOUP/MOUP/Data/Services/WorkplaceService.swift index 302aa13c..aaea6ac3 100644 --- a/MOUP/MOUP/Data/Services/WorkplaceService.swift +++ b/MOUP/MOUP/Data/Services/WorkplaceService.swift @@ -21,6 +21,8 @@ protocol WorkplaceServiceProtocol: AnyObject { func deleteWorkplace(workplaceId: Int) async throws func fetchWorkplaceDetail(workplaceId: Int) async throws -> WorkplaceDetailResponseDTO func updateWorkplace(workplaceId: Int, request: UpdateWorkplaceRequestDTO) async throws -> Void + func approveJoinRequest(workplaceId: Int, workerId: Int) async throws + func rejectJoinRequest(workplaceId: Int, workerId: Int) async throws } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: String(describing: "WorkplaceService")) @@ -288,4 +290,66 @@ final class WorkplaceService: WorkplaceServiceProtocol { throw NetworkError.serverError } } + + func approveJoinRequest(workplaceId: Int, workerId: Int) async throws { + let request = session.request( + WorkplaceRouter.approveJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + ) + let response = await request.serializingData().response + + guard let statusCode = response.response?.statusCode else { + throw NetworkError.noResponse + } + + switch statusCode { + case 200...209: + return + case 400: + throw NetworkError.badRequest + case 403: + throw NetworkError.forbidden + case 404: + throw NetworkError.notFound + case 422: + throw NetworkError.invalidField + case 500: + throw NetworkError.serverError + default: + throw NetworkError.unknown + } + } + + func rejectJoinRequest(workplaceId: Int, workerId: Int) async throws { + let request = session.request( + WorkplaceRouter.rejectJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + ) + let response = await request.serializingData().response + + guard let statusCode = response.response?.statusCode else { + throw NetworkError.noResponse + } + + switch statusCode { + case 200...209: + return + case 400: + throw NetworkError.badRequest + case 403: + throw NetworkError.forbidden + case 404: + throw NetworkError.notFound + case 422: + throw NetworkError.invalidField + case 500: + throw NetworkError.serverError + default: + throw NetworkError.unknown + } + } } diff --git a/MOUP/MOUP/Domain/Entities/Notification.swift b/MOUP/MOUP/Domain/Entities/Notification.swift index 8e6f531d..efff10d7 100644 --- a/MOUP/MOUP/Domain/Entities/Notification.swift +++ b/MOUP/MOUP/Domain/Entities/Notification.swift @@ -16,7 +16,26 @@ struct UserNotification { let sentAt: Date let readAt: Date? + let type: PushNotificationType? + let metadata: NotificationMetadata? + var isRead: Bool { return readAt != nil } } + +struct NotificationMetadata { + let type: PushNotificationType? + let workerId: Int? + let workplaceId: Int? + + init( + type: PushNotificationType? = nil, + workerId: Int? = nil, + workplaceId: Int? = nil + ) { + self.type = type + self.workerId = workerId + self.workplaceId = workplaceId + } +} diff --git a/MOUP/MOUP/Domain/Interfaces/Repositories/WorkplaceRepositoryProtocol.swift b/MOUP/MOUP/Domain/Interfaces/Repositories/WorkplaceRepositoryProtocol.swift index 44e76b5c..1354b4ea 100644 --- a/MOUP/MOUP/Domain/Interfaces/Repositories/WorkplaceRepositoryProtocol.swift +++ b/MOUP/MOUP/Domain/Interfaces/Repositories/WorkplaceRepositoryProtocol.swift @@ -17,4 +17,6 @@ protocol WorkplaceRepositoryProtocol: AnyObject { func deleteWorkplace(workplaceId: Int) async throws func fetchWorkplaceDetail(workplaceId: Int) async throws -> WorkplaceDetailResponseDTO func updateWorkplace(workplaceId: Int, request: UpdateWorkplaceRequestDTO) async throws + func approveJoinRequest(workplaceId: Int, workerId: Int) async throws + func rejectJoinRequest(workplaceId: Int, workerId: Int) async throws } diff --git a/MOUP/MOUP/Domain/Interfaces/UseCases/WorkplaceUseCaseProtocol.swift b/MOUP/MOUP/Domain/Interfaces/UseCases/WorkplaceUseCaseProtocol.swift index e76171a7..0ed3db68 100644 --- a/MOUP/MOUP/Domain/Interfaces/UseCases/WorkplaceUseCaseProtocol.swift +++ b/MOUP/MOUP/Domain/Interfaces/UseCases/WorkplaceUseCaseProtocol.swift @@ -18,4 +18,6 @@ protocol WorkplaceUseCaseProtocol: AnyObject { func deleteWorkplace(workplaceId: Int) async throws func fetchWorkplaceDetail(workplaceId: Int) async throws -> WorkplaceDetailResponseDTO func updateWorkplace(workplaceId: Int, request: UpdateWorkplaceRequestDTO) async throws + func approveJoinRequest(workplaceId: Int, workerId: Int) async throws + func rejectJoinRequest(workplaceId: Int, workerId: Int) async throws } diff --git a/MOUP/MOUP/Domain/UseCases/WorkplaceUseCase.swift b/MOUP/MOUP/Domain/UseCases/WorkplaceUseCase.swift index b7901558..4f2cabff 100644 --- a/MOUP/MOUP/Domain/UseCases/WorkplaceUseCase.swift +++ b/MOUP/MOUP/Domain/UseCases/WorkplaceUseCase.swift @@ -53,4 +53,18 @@ final class WorkplaceUseCase: WorkplaceUseCaseProtocol { func updateWorkplace(workplaceId: Int, request: UpdateWorkplaceRequestDTO) async throws { try await workplaceRepository.updateWorkplace(workplaceId: workplaceId, request: request) } + + func approveJoinRequest(workplaceId: Int, workerId: Int) async throws { + try await workplaceRepository.approveJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + } + + func rejectJoinRequest(workplaceId: Int, workerId: Int) async throws { + try await workplaceRepository.rejectJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + } } diff --git a/MOUP/MOUP/Presentation/MyPage/ViewController/MyPageViewController.swift b/MOUP/MOUP/Presentation/MyPage/ViewController/MyPageViewController.swift index 99f3fedc..04f1c448 100644 --- a/MOUP/MOUP/Presentation/MyPage/ViewController/MyPageViewController.swift +++ b/MOUP/MOUP/Presentation/MyPage/ViewController/MyPageViewController.swift @@ -20,6 +20,7 @@ final class MyPageViewController: UIViewController { private let disposeBag = DisposeBag() private let viewDidLoadSubject = PublishSubject() + private var userId: Int? private var routineSelectionCoordinator: RoutineSelectionCoordinator? @@ -117,6 +118,7 @@ private extension MyPageViewController { output.profile .compactMap { $0 } .drive(with: self) { owner, profile in + owner.userId = profile.userId owner.mypageView.updateProfile(profile) } .disposed(by: disposeBag) @@ -181,13 +183,15 @@ private extension MyPageViewController { mailComposer.setMessageBody( """ - 문의 내용: + [문의 내용] + 여기에 문의 내용을 작성해주세요. - -------------------------- - UID: \("uid") + --------------------------------------- + [기기 정보] + User ID: \(userId ?? -1) iOS Version: \(UIDevice.current.systemVersion) App Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") - -------------------------- + --------------------------------------- """, isHTML: false ) diff --git a/MOUP/MOUP/Presentation/Notification/View/NotificationListView.swift b/MOUP/MOUP/Presentation/Notification/View/NotificationListView.swift index fc48a49d..98b75265 100644 --- a/MOUP/MOUP/Presentation/Notification/View/NotificationListView.swift +++ b/MOUP/MOUP/Presentation/Notification/View/NotificationListView.swift @@ -10,6 +10,7 @@ import Then import SnapKit import RxSwift import RxCocoa +import OSLog final class NotificationListView: UIView { @@ -19,6 +20,16 @@ final class NotificationListView: UIView { fileprivate let markAllAsReadSubject = PublishSubject() fileprivate let deleteAllSubject = PublishSubject() fileprivate let deleteSubject = PublishSubject() + fileprivate let approveSubject = PublishSubject<( + notificationId: Int, + workplaceId: Int, + workerId: Int + )>() + fileprivate let rejectSubject = PublishSubject<( + notificationId: Int, + workplaceId: Int, + workerId: Int + )>() private var notifications: [UserNotification] = [] // MARK: - UI Components @@ -182,6 +193,24 @@ extension NotificationListView: UITableViewDataSource { let notification = notifications[indexPath.row] cell.configure(with: notification) + if notification.type == .inviteRequest, + let metadata = notification.metadata, + let workplaceId = metadata.workplaceId, + let workerId = metadata.workerId { + + cell.onApprove = { [weak self] notificationId in + self?.approveSubject.onNext( + (notificationId, workplaceId, workerId) + ) + } + + cell.onReject = { [weak self] notificationId in + self?.rejectSubject.onNext( + (notificationId, workplaceId, workerId) + ) + } + } + return cell } } @@ -193,7 +222,9 @@ extension NotificationListView: UITableViewDelegate { } func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - let deleteAction = UIContextualAction(style: .destructive, title: "삭제") { [weak self] _, _, completion in + let deleteAction = UIContextualAction( + style: .destructive, title: "삭제" + ) { [weak self] _, _, completion in guard let self else { completion(false) return @@ -228,4 +259,16 @@ extension Reactive where Base: NotificationListView { var deleteTapped: ControlEvent { ControlEvent(events: base.deleteSubject) } + + var approveTapped: Observable<( + notificationId: Int, workplaceId: Int, workerId: Int + )> { + return base.approveSubject.asObservable() + } + + var rejectTapped: Observable<( + notificationId: Int, workplaceId: Int, workerId: Int + )> { + return base.rejectSubject.asObservable() + } } diff --git a/MOUP/MOUP/Presentation/Notification/View/NotificationTableViewCell.swift b/MOUP/MOUP/Presentation/Notification/View/NotificationTableViewCell.swift index 69a97bb1..67a5c79d 100644 --- a/MOUP/MOUP/Presentation/Notification/View/NotificationTableViewCell.swift +++ b/MOUP/MOUP/Presentation/Notification/View/NotificationTableViewCell.swift @@ -11,9 +11,21 @@ import SnapKit final class NotificationTableViewCell: UITableViewCell { + // MARK: - Button State + + private enum ButtonState { + case initial + case approved + case rejected + } + // MARK: - Properties static let identifier = "NotificationTableViewCell" + var onApprove: ((Int) -> Void)? + var onReject: ((Int) -> Void)? + private var currentState: ButtonState = .initial + private var currentNotificationId: Int? // MARK: - UI Components @@ -39,6 +51,31 @@ final class NotificationTableViewCell: UITableViewCell { $0.textColor = .gray400 } + private lazy var actionButtonStackView = UIStackView().then { + $0.axis = .horizontal + $0.spacing = 8 + $0.distribution = .fillEqually + $0.isHidden = true + } + + private lazy var rejectButton = UIButton().then { + $0.setTitle("거절", for: .normal) + $0.setTitleColor(.gray700, for: .normal) + $0.titleLabel?.font = .buttonSemibold(14) + $0.backgroundColor = .gray200 + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + + private lazy var approveButton = UIButton().then { + $0.setTitle("승인", for: .normal) + $0.setTitleColor(.white, for: .normal) + $0.titleLabel?.font = .buttonSemibold(14) + $0.backgroundColor = .primary500 + $0.layer.cornerRadius = 8 + $0.clipsToBounds = true + } + // MARK: - Initializer override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -59,11 +96,30 @@ final class NotificationTableViewCell: UITableViewCell { titleLabel.text = nil contentLabel.text = nil timeLabel.text = nil + + actionButtonStackView.isHidden = true + onApprove = nil + onReject = nil + approveButton.isHidden = false + rejectButton.isHidden = false + approveButton.alpha = 1.0 + rejectButton.alpha = 1.0 + approveButton.isEnabled = true + rejectButton.isEnabled = true + actionButtonStackView.distribution = .fillEqually + + contentLabel.snp.removeConstraints() + actionButtonStackView.snp.removeConstraints() } // MARK: - Public Methods func configure(with notification: UserNotification) { + if currentNotificationId != notification.id { + currentState = .initial + currentNotificationId = notification.id + } + titleLabel.text = notification.title contentLabel.text = notification.content @@ -76,8 +132,85 @@ final class NotificationTableViewCell: UITableViewCell { } timeLabel.text = formatTimeAgo(notification.sentAt) + + let shouldShowButtons = notification.type == .inviteRequest || + notification.type == .inviteApproved || + notification.type == .inviteRejected + + if shouldShowButtons { + actionButtonStackView.isHidden = false + + contentLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(4) + $0.leading.equalTo(titleLabel) + $0.trailing.equalToSuperview().inset(16) + } + + actionButtonStackView.snp.makeConstraints { + $0.top.equalTo(contentLabel.snp.bottom).offset(12) + $0.leading.equalTo(titleLabel) + $0.trailing.equalToSuperview().inset(16) + $0.height.equalTo(36) + $0.bottom.equalToSuperview().inset(12) + } + + configureButtonState(for: notification.type) + } else { + actionButtonStackView.isHidden = true + + contentLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(4) + $0.leading.equalTo(titleLabel) + $0.trailing.equalToSuperview().inset(16) + $0.bottom.equalToSuperview().inset(12) + } + } } + private func configureButtonState(for type: PushNotificationType?) { + switch type { + case .inviteRequest: + currentState = .initial + case .inviteApproved: + currentState = .approved + case .inviteRejected: + currentState = .rejected + default: + break + } + + applyButtonState() + } + + private func applyButtonState() { + switch currentState { + case .initial: + approveButton.isHidden = false + rejectButton.isHidden = false + approveButton.alpha = 1.0 + rejectButton.alpha = 1.0 + approveButton.isEnabled = true + rejectButton.isEnabled = true + actionButtonStackView.distribution = .fillEqually + case .approved: + approveButton.isHidden = false + rejectButton.isHidden = true + approveButton.alpha = 1.0 + rejectButton.alpha = 0 + approveButton.isEnabled = false + rejectButton.isEnabled = false + actionButtonStackView.distribution = .fill + case .rejected: + approveButton.isHidden = true + rejectButton.isHidden = false + approveButton.alpha = 0 + rejectButton.alpha = 1.0 + approveButton.isEnabled = false + rejectButton.isEnabled = false + actionButtonStackView.distribution = .fill + } + } + private func formatTimeAgo(_ date: Date) -> String { let calendar = Calendar.current let now = Date() @@ -104,26 +237,37 @@ final class NotificationTableViewCell: UITableViewCell { } private extension NotificationTableViewCell { + // MARK: - configure func configure() { setHierarchy() setStyles() setConstraints() + setActions() } + // MARK: - setHierarchy func setHierarchy() { contentView.addSubviews( statusIcon, titleLabel, contentLabel, - timeLabel + timeLabel, + actionButtonStackView + ) + + actionButtonStackView.addArrangedSubviews( + rejectButton, + approveButton ) } + // MARK: - setStyles func setStyles() { selectionStyle = .none backgroundColor = .white } + // MARK: - setConstraints func setConstraints() { statusIcon.snp.makeConstraints { $0.centerY.equalToSuperview() @@ -145,7 +289,72 @@ private extension NotificationTableViewCell { $0.top.equalTo(titleLabel.snp.bottom).offset(4) $0.leading.equalTo(titleLabel) $0.trailing.equalToSuperview().inset(16) - $0.bottom.equalToSuperview().inset(12) + } + } + + // MARK: - setActions + func setActions() { + approveButton.addTarget( + self, + action: #selector(handleApprove), + for: .touchUpInside + ) + rejectButton.addTarget( + self, + action: #selector(handleReject), + for: .touchUpInside + ) + } + + @objc func handleApprove() { + guard currentState == .initial, + let notificationId = currentNotificationId else { return } + currentState = .approved + animateToApproved() + onApprove?(notificationId) + } + + @objc func handleReject() { + guard currentState == .initial, + let notificationId = currentNotificationId else { return } + currentState = .rejected + animateToRejected() + onReject?(notificationId) + } + + // MARK: - Animation Methods + + func animateToApproved() { + approveButton.isEnabled = false + rejectButton.isEnabled = false + + UIView.animate( + withDuration: 0.3, + delay: 0, + options: .curveEaseInOut + ) { + self.rejectButton.alpha = 0 + self.layoutIfNeeded() + } completion: { _ in + self.rejectButton.isHidden = true + self.actionButtonStackView.distribution = .fill + } + } + + func animateToRejected() { + approveButton.isEnabled = false + rejectButton.isEnabled = false + + UIView.animate( + withDuration: 0.3, + delay: 0, + options: .curveEaseInOut + ) { + self.approveButton.alpha = 0 + self.layoutIfNeeded() + } completion: { _ in + self.approveButton.isHidden = true + self.actionButtonStackView.distribution = .fill } } } diff --git a/MOUP/MOUP/Presentation/Notification/ViewController/NotificationListViewController.swift b/MOUP/MOUP/Presentation/Notification/ViewController/NotificationListViewController.swift index ddbb7d49..34820888 100644 --- a/MOUP/MOUP/Presentation/Notification/ViewController/NotificationListViewController.swift +++ b/MOUP/MOUP/Presentation/Notification/ViewController/NotificationListViewController.swift @@ -10,7 +10,7 @@ import RxSwift final class NotificationListViewController: UIViewController { - // MARK; - Properties + // MARK: - Properties private let notificationListView = NotificationListView() private let viewModel: NotificationListViewModel @@ -18,6 +18,9 @@ final class NotificationListViewController: UIViewController { private let viewDidLoadSubject = PublishSubject() private let refreshSubject = PublishSubject() + + private var pushNotificationMetadata: (type: String?, workerId: Int?, workplaceId: Int?)? + private var pendingPushData: (id: Int?, metadata: NotificationMetadata?)? // MARK: - Lifecycle @@ -34,10 +37,26 @@ final class NotificationListViewController: UIViewController { } // MARK: - Initializer - - init(viewModel: NotificationListViewModel) { + + init( + viewModel: NotificationListViewModel, + pushType: String? = nil, + pushWorkerId: Int? = nil, + pushWorkplaceId: Int? = nil, + pushNotificationId: Int? = nil + ) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) + + let pushNotificationType = PushNotificationType(from: pushType) + if pushNotificationType != nil || pushWorkerId != nil || pushWorkplaceId != nil { + let metadata = NotificationMetadata( + type: pushNotificationType, + workerId: pushWorkerId, + workplaceId: pushWorkplaceId + ) + pendingPushData = (id: pushNotificationId, metadata: metadata) + } } required init?(coder: NSCoder) { @@ -92,7 +111,9 @@ private extension NotificationListViewController { markAllReadTapped: notificationListView.rx.markAllReadTapped.asObservable(), deleteAllTapped: deleteAllConfirmed, refreshTrigger: refreshSubject.asObservable(), - deleteTapped: notificationListView.rx.deleteTapped.asObservable() + deleteTapped: notificationListView.rx.deleteTapped.asObservable(), + approveTapped: notificationListView.rx.approveTapped.asObservable(), + rejectTapped: notificationListView.rx.rejectTapped.asObservable() ) let output = viewModel.transform(input) @@ -109,23 +130,37 @@ private extension NotificationListViewController { output.notifications .drive(with: self) { owner, notifications in - owner.notificationListView.updateNotifications(notifications) + let readCount = notifications.filter { $0.isRead }.count + let updatedNotifications = owner.applyPushMetadata(to: notifications) + let updatedReadCount = updatedNotifications.filter { $0.isRead }.count + + owner.notificationListView.updateNotifications(updatedNotifications) } .disposed(by: disposeBag) output.error .emit(with: self) { owner, message in - print("에러: \(message)") // TODO: - 에러 알림 표시 } .disposed(by: disposeBag) output.unreadCount .drive(with: self) { owner, count in - print("읽지 않은 알림: \(count)개") // TODO: - 배지 업데이트 } .disposed(by: disposeBag) + + output.approveSuccess + .emit(with: self) { owner, _ in + // TODO: - 성공 메시지 표시 + } + .disposed(by: disposeBag) + + output.rejectSuccess + .emit(with: self) { owner, _ in + // TODO: - 성공 메시지 표시 + } + .disposed(by: disposeBag) } func observerPushNotifications() { @@ -135,16 +170,99 @@ private extension NotificationListViewController { name: .pushNotificationReceived, object: nil ) - + NotificationCenter.default.addObserver( self, selector: #selector(handlePushNotificationReceived), name: UIApplication.didBecomeActiveNotification, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handlePushNotificationTapped), + name: .pushNotificationTapped, + object: nil + ) } - + @objc func handlePushNotificationReceived() { refreshSubject.onNext(()) } + + @objc func handlePushNotificationTapped(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let notificationId = userInfo[PushNotificationKey.notificationId] as? Int + else { return } + + let typeString = userInfo[PushNotificationKey.type] as? String + let pushNotificationType = PushNotificationType(from: typeString) + let workerId = userInfo[PushNotificationKey.workerId] as? Int + let workplaceId = userInfo[PushNotificationKey.workplaceId] as? Int + + let metadata = (pushNotificationType != nil || workerId != nil || workplaceId != nil) + ? NotificationMetadata(type: pushNotificationType, workerId: workerId, workplaceId: workplaceId) + : nil + + pendingPushData = (id: notificationId, metadata: metadata) + refreshSubject.onNext(()) + } + + private func applyPushMetadata(to notifications: [UserNotification]) -> [UserNotification] { + guard let pending = pendingPushData else { return notifications } + + guard !notifications.isEmpty else { + return notifications + } + + defer { + pendingPushData = nil + } + + return notifications.map { notification in + let shouldApplyMetadata: Bool + if let pendingId = pending.id { + shouldApplyMetadata = notification.id == pendingId + } else if let pushMeta = pending.metadata { + let typeMatch = pushMeta.type != nil && notification.title.contains("참가 요청") + let workerIdMatch = pushMeta.workerId != nil + let workplaceIdMatch = pushMeta.workplaceId != nil + + shouldApplyMetadata = typeMatch && workerIdMatch && workplaceIdMatch + } else { + shouldApplyMetadata = false + } + + guard shouldApplyMetadata else { return notification } + + let finalMetadata: NotificationMetadata? + if let pushMeta = pending.metadata { + let mergedType = pushMeta.type ?? notification.type + let mergedWorkerId = pushMeta.workerId ?? notification.metadata?.workerId + let mergedWorkplaceId = pushMeta.workplaceId ?? notification.metadata?.workplaceId + + finalMetadata = NotificationMetadata( + type: mergedType, + workerId: mergedWorkerId, + workplaceId: mergedWorkplaceId + ) + } else { + finalMetadata = notification.metadata + } + + let finalType = pending.metadata?.type ?? notification.type + + return UserNotification( + id: notification.id, + senderId: notification.senderId, + receiverId: notification.receiverId, + title: notification.title, + content: notification.content, + sentAt: notification.sentAt, + readAt: notification.readAt, + type: finalType, + metadata: finalMetadata + ) + } + } } diff --git a/MOUP/MOUP/Presentation/Notification/ViewModel/NotificationListViewModel.swift b/MOUP/MOUP/Presentation/Notification/ViewModel/NotificationListViewModel.swift index d540c6c9..5fb4d7a3 100644 --- a/MOUP/MOUP/Presentation/Notification/ViewModel/NotificationListViewModel.swift +++ b/MOUP/MOUP/Presentation/Notification/ViewModel/NotificationListViewModel.swift @@ -20,6 +20,12 @@ final class NotificationListViewModel { let deleteAllTapped: Observable let refreshTrigger: Observable let deleteTapped: Observable + let approveTapped: Observable<( + notificationId: Int, workplaceId: Int, workerId: Int + )> + let rejectTapped: Observable<( + notificationId: Int, workplaceId: Int, workerId: Int + )> } // MARK: - Output @@ -29,17 +35,59 @@ final class NotificationListViewModel { let isLoading: Driver let error: Signal let unreadCount: Driver + let approveSuccess: Signal + let rejectSuccess: Signal } // MARK: - Properties private let notificationUseCase: NotificationUseCaseProtocol + private let workplaceUseCase: WorkplaceUseCaseProtocol private let disposeBag = DisposeBag() + private var approvedNotificationIds: Set = [] + private var rejectedNotificationIds: Set = [] // MARK: - Initializer - init(notificationUseCase: NotificationUseCaseProtocol) { + init( + notificationUseCase: NotificationUseCaseProtocol, + workplaceUseCase: WorkplaceUseCaseProtocol + ) { self.notificationUseCase = notificationUseCase + self.workplaceUseCase = workplaceUseCase + } + + // MARK: - Helper Methods + + private func applyLocalState(to notifications: [UserNotification]) -> [UserNotification] { + return notifications.map { notification in + if approvedNotificationIds.contains(notification.id) { + return UserNotification( + id: notification.id, + senderId: notification.senderId, + receiverId: notification.receiverId, + title: notification.title, + content: notification.content, + sentAt: notification.sentAt, + readAt: notification.readAt, + type: .inviteApproved, + metadata: notification.metadata + ) + } else if rejectedNotificationIds.contains(notification.id) { + return UserNotification( + id: notification.id, + senderId: notification.senderId, + receiverId: notification.receiverId, + title: notification.title, + content: notification.content, + sentAt: notification.sentAt, + readAt: notification.readAt, + type: .inviteRejected, + metadata: notification.metadata + ) + } + return notification + } } // MARK: - Transform @@ -48,7 +96,8 @@ final class NotificationListViewModel { let loadingRelay = BehaviorRelay(value: false) let notificationsRelay = BehaviorRelay<[UserNotification]>(value: []) let errorRelay = PublishRelay() - + let approveSuccessRelay = PublishRelay() + let rejectSuccessRelay = PublishRelay() let fetchTrigger = Observable.merge( input.viewDidLoad, input.refreshTrigger @@ -64,9 +113,9 @@ final class NotificationListViewModel { return Observable.create { observer in Task { do { - let notifications = try await self.notificationUseCase.fetchNotifications() - - let sortedNotification = notifications.sorted { $0.sentAt > $1.sentAt } + let fetchedNotifications = try await self.notificationUseCase.fetchNotifications() + + let sortedNotification = fetchedNotifications.sorted { $0.sentAt > $1.sentAt } observer.onNext(sortedNotification) observer.onCompleted() @@ -80,6 +129,10 @@ final class NotificationListViewModel { return Disposables.create() } } + .map { [weak self] notifications -> [UserNotification] in + guard let self else { return notifications } + return self.applyLocalState(to: notifications) + } .bind(to: notificationsRelay) .disposed(by: disposeBag) @@ -104,8 +157,9 @@ final class NotificationListViewModel { } } .withLatestFrom(notificationsRelay) { tappedNotificationId, notifications -> [UserNotification] in - return notifications.map { notification in - if notification.id == tappedNotificationId && !notification.isRead { + + let updatedNotifications = notifications.map { notification in + if notification.id == tappedNotificationId { return UserNotification( id: notification.id, senderId: notification.senderId, @@ -113,11 +167,15 @@ final class NotificationListViewModel { title: notification.title, content: notification.content, sentAt: notification.sentAt, - readAt: Date() + readAt: Date(), + type: notification.type, + metadata: notification.metadata ) } return notification } + + return updatedNotifications } .bind(to: notificationsRelay) .disposed(by: disposeBag) @@ -149,7 +207,9 @@ final class NotificationListViewModel { title: notification.title, content: notification.content, sentAt: notification.sentAt, - readAt: notification.readAt ?? Date() + readAt: notification.readAt ?? Date(), + type: notification.type, + metadata: notification.metadata ) } } @@ -207,11 +267,139 @@ final class NotificationListViewModel { .bind(to: notificationsRelay) .disposed(by: disposeBag) + input.approveTapped + .do(onNext: { [weak self] (notificationId, _, _) in + self?.approvedNotificationIds.insert(notificationId) + }) + .flatMapLatest { [weak self] (_, workplaceId, workerId) -> Observable in + guard let self else { return .empty() } + + return Observable.create { observer in + Task { + do { + try await self.workplaceUseCase.approveJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + observer.onNext(()) + observer.onCompleted() + } catch { + errorRelay.accept("승인에 실패했습니다.") + observer.onCompleted() + } + } + return Disposables.create() + } + } + .do(onNext: { [weak self] _ in + guard let self else { return } + Task { + do { + let currentNotifications = notificationsRelay.value + let fetchedNotifications = try await self.notificationUseCase.fetchNotifications() + let mergedNotifications = fetchedNotifications.map { fetched -> UserNotification in + if let current = currentNotifications.first( + where: { $0.id == fetched.id } + ), + current.isRead { + return UserNotification( + id: fetched.id, + senderId: fetched.senderId, + receiverId: fetched.receiverId, + title: fetched.title, + content: fetched.content, + sentAt: fetched.sentAt, + readAt: current.readAt, + type: fetched.type, + metadata: fetched.metadata + ) + } + return fetched + } + + let sortedNotification = mergedNotifications.sorted { $0.sentAt > $1.sentAt } + let notificationsWithLocalState = self.applyLocalState(to: sortedNotification) + + notificationsRelay.accept(notificationsWithLocalState) + } catch { + // 에러 + } + } + }) + .bind(to: approveSuccessRelay) + .disposed(by: disposeBag) + + input.rejectTapped + .do(onNext: { [weak self] (notificationId, _, _) in + self?.rejectedNotificationIds.insert(notificationId) + }) + .flatMapLatest { [weak self] (_, workplaceId, workerId) -> Observable in + guard let self else { return .empty() } + + return Observable.create { observer in + Task { + do { + try await self.workplaceUseCase.rejectJoinRequest( + workplaceId: workplaceId, + workerId: workerId + ) + observer.onNext(()) + observer.onCompleted() + } catch { + errorRelay.accept("거절에 실패했습니다.") + observer.onCompleted() + } + } + return Disposables.create() + } + } + .do(onNext: { [weak self] _ in + guard let self else { return } + Task { + do { + let currentNotifications = notificationsRelay.value + + let fetchedNotifications = try await self.notificationUseCase.fetchNotifications() + + let mergedNotifications = fetchedNotifications.map { fetched -> UserNotification in + if let current = currentNotifications.first( + where: { $0.id == fetched.id } + ), + current.isRead { + return UserNotification( + id: fetched.id, + senderId: fetched.senderId, + receiverId: fetched.receiverId, + title: fetched.title, + content: fetched.content, + sentAt: fetched.sentAt, + readAt: current.readAt, + type: fetched.type, + metadata: fetched.metadata + ) + } + return fetched + } + + let sortedNotification = mergedNotifications.sorted { $0.sentAt > $1.sentAt } + let notificationsWithLocalState = self.applyLocalState(to: sortedNotification) + + notificationsRelay.accept(notificationsWithLocalState) + } catch { + // 에러 + } + } + }) + .bind(to: rejectSuccessRelay) + .disposed(by: disposeBag) + return Output( notifications: notificationsRelay.asDriver(), isLoading: loadingRelay.asDriver(), error: errorRelay.asSignal(), - unreadCount: unreadCount.asDriver(onErrorJustReturn: 0) + unreadCount: unreadCount.asDriver(onErrorJustReturn: 0), + approveSuccess: approveSuccessRelay.asSignal(), + rejectSuccess: rejectSuccessRelay.asSignal() ) } } diff --git a/MOUP/MOUP/Presentation/Routine/View/AddRoutineView.swift b/MOUP/MOUP/Presentation/Routine/View/AddRoutineView.swift index f409b43a..89de6d9b 100644 --- a/MOUP/MOUP/Presentation/Routine/View/AddRoutineView.swift +++ b/MOUP/MOUP/Presentation/Routine/View/AddRoutineView.swift @@ -18,17 +18,32 @@ final class AddRoutineView: UIView { enum Section { case main } fileprivate lazy var dataSource = UITableViewDiffableDataSource( tableView: tableView - ) { tableView, indexPath, item in - guard let cell = tableView.dequeueReusableCell(withIdentifier: TodoCell.id, for: indexPath) as? TodoCell else { - fatalError("TodoCell을 생성할 수 없습니다.") + ) { [weak self] tableView, indexPath, item in + guard let self, + let cell = tableView.dequeueReusableCell( + withIdentifier: TodoCell.id, + for: indexPath + ) as? TodoCell else { + assertionFailure("TodoCell 생성 실패 - 셀이 등록되지 않았거나 타입이 잘못됨") + return UITableViewCell() } + cell.disposeBag = DisposeBag() + cell.textField.text = item.content + + cell.textField.rx.text.orEmpty + .skip(1) + .map { text in + return (index: indexPath.row, text: text) + } + .bind(to: self.itemTextChangeRelay) + .disposed(by: cell.disposeBag) + return cell } fileprivate let itemTextChangeRelay = PublishRelay<(index: Int, text: String)>() fileprivate let itemMovedRelay = PublishRelay<(source: Int, destination: Int)>() fileprivate let itemDeleteRelay = PublishRelay() - fileprivate var isHandlingDragDrop = false // MARK: - UI Components @@ -264,17 +279,6 @@ private extension AddRoutineView { } extension AddRoutineView: UITableViewDelegate { - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let cell = cell as? TodoCell else { return } - cell.textField.tag = indexPath.row - cell.textField.removeTarget(nil, action: nil, for: .editingChanged) - cell.textField.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged) - } - - @objc private func textChanged(_ tf: UITextField) { - itemTextChangeRelay.accept((index: tf.tag, text: tf.text ?? "")) - } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let deleteAction = UIContextualAction(style: .destructive, title: "삭제") { [weak self] _, _, completion in self?.itemDeleteRelay.accept(indexPath.row) @@ -335,22 +339,10 @@ extension AddRoutineView: UITableViewDropDelegate { _ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator ) { - let destinationIndexPath: IndexPath - if let indexPath = coordinator.destinationIndexPath { - destinationIndexPath = indexPath - } else { - let section = tableView.numberOfSections - 1 - let row = tableView.numberOfRows(inSection: section) - destinationIndexPath = IndexPath(row: row, section: section) - } - - guard let item = coordinator.items.first, + guard let destinationIndexPath = coordinator.destinationIndexPath, + let item = coordinator.items.first, let sourceIndexPath = item.sourceIndexPath else { return } - - coordinator.drop(item.dragItem, toRowAt: destinationIndexPath) - - self.isHandlingDragDrop = true - + self.itemMovedRelay.accept( (source: sourceIndexPath.row, destination: destinationIndexPath.row) ) @@ -380,10 +372,7 @@ extension Reactive where Base: AddRoutineView { snapshot.appendSections([.main]) snapshot.appendItems(items, toSection: .main) - let shouldAnimate = !view.isHandlingDragDrop - view.dataSource.apply(snapshot, animatingDifferences: shouldAnimate) - - view.isHandlingDragDrop = false + view.dataSource.apply(snapshot, animatingDifferences: false) } } diff --git a/MOUP/MOUP/Presentation/Routine/View/EditRoutineView.swift b/MOUP/MOUP/Presentation/Routine/View/EditRoutineView.swift index 42fe1e57..433f03e2 100644 --- a/MOUP/MOUP/Presentation/Routine/View/EditRoutineView.swift +++ b/MOUP/MOUP/Presentation/Routine/View/EditRoutineView.swift @@ -20,9 +20,19 @@ final class EditRoutineView: UIView { tableView: tableView ) { tableView, indexPath, item in guard let cell = tableView.dequeueReusableCell(withIdentifier: TodoCell.id, for: indexPath) as? TodoCell else { - fatalError("TodoCell을 생성할 수 없습니다.") + assertionFailure("TodoCell 생성 실패 - 셀이 등록되지 않았거나 타입이 잘못됨") + return UITableViewCell() } + cell.disposeBag = DisposeBag() + cell.textField.text = item.content + + cell.textField.rx.text.orEmpty + .skip(1) + .map { text in (index: indexPath.row, text: text) } + .bind(to: self.itemTextChangeRelay) + .disposed(by: cell.disposeBag) + return cell } fileprivate let itemTextChangeRelay = PublishRelay<(index: Int, text: String)>() @@ -249,17 +259,6 @@ private extension EditRoutineView { } extension EditRoutineView: UITableViewDelegate { - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - guard let cell = cell as? TodoCell else { return } - cell.textField.tag = indexPath.row - cell.textField.removeTarget(nil, action: nil, for: .editingChanged) - cell.textField.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged) - } - - @objc private func textChanged(_ tf: UITextField) { - itemTextChangeRelay.accept((index: tf.tag, text: tf.text ?? "")) - } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let deleteAction = UIContextualAction(style: .destructive, title: "삭제") { [weak self] _, _, completion in self?.itemDeleteRelay.accept(indexPath.row) @@ -280,7 +279,6 @@ extension EditRoutineView: UITableViewDelegate { } extension EditRoutineView: UITableViewDragDelegate { - func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { diff --git a/MOUP/MOUP/Presentation/Routine/View/TodoCell.swift b/MOUP/MOUP/Presentation/Routine/View/TodoCell.swift index 4173b11c..3a2a3ef1 100644 --- a/MOUP/MOUP/Presentation/Routine/View/TodoCell.swift +++ b/MOUP/MOUP/Presentation/Routine/View/TodoCell.swift @@ -8,10 +8,13 @@ import UIKit import Then import SnapKit +import RxSwift final class TodoCell: UITableViewCell { static let id = "TodoCell" + + var disposeBag = DisposeBag() // MARK: - UI Components @@ -49,6 +52,11 @@ final class TodoCell: UITableViewCell { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() + } } private extension TodoCell { diff --git a/MOUP/MOUP/Presentation/Routine/ViewController/AddRoutineViewController.swift b/MOUP/MOUP/Presentation/Routine/ViewController/AddRoutineViewController.swift index 16ed0577..4b0e4428 100644 --- a/MOUP/MOUP/Presentation/Routine/ViewController/AddRoutineViewController.swift +++ b/MOUP/MOUP/Presentation/Routine/ViewController/AddRoutineViewController.swift @@ -150,6 +150,7 @@ private extension AddRoutineViewController { comment: message ) alert.modalTransitionStyle = .crossDissolve + alert.modalPresentationStyle = .overFullScreen owner.present(alert, animated: true) } diff --git a/MOUP/MOUP/Presentation/Routine/ViewController/EditRoutineViewController.swift b/MOUP/MOUP/Presentation/Routine/ViewController/EditRoutineViewController.swift index 4c38182a..bb6e5d22 100644 --- a/MOUP/MOUP/Presentation/Routine/ViewController/EditRoutineViewController.swift +++ b/MOUP/MOUP/Presentation/Routine/ViewController/EditRoutineViewController.swift @@ -155,6 +155,7 @@ private extension EditRoutineViewController { comment: message ) alert.modalTransitionStyle = .crossDissolve + alert.modalPresentationStyle = .overFullScreen owner.present(alert, animated: true) } @@ -179,6 +180,7 @@ private extension EditRoutineViewController { comment: message ) alert.modalTransitionStyle = .crossDissolve + alert.modalPresentationStyle = .overFullScreen owner.present(alert, animated: true) { owner.navigationController?.popViewController(animated: true) diff --git a/MOUP/MOUP/Presentation/Routine/ViewModel/AddRoutineViewModel.swift b/MOUP/MOUP/Presentation/Routine/ViewModel/AddRoutineViewModel.swift index 540e54e3..287b1cbc 100644 --- a/MOUP/MOUP/Presentation/Routine/ViewModel/AddRoutineViewModel.swift +++ b/MOUP/MOUP/Presentation/Routine/ViewModel/AddRoutineViewModel.swift @@ -219,7 +219,7 @@ final class AddRoutineViewModel { validationFocusRelay.accept(.alarmTime) return .empty() } - let alarmTimeString = String(format: "%d:%02d", hour, minute) + let alarmTimeString = String(format: "%02d:%02d", hour, minute) let tasks = validItems.map { item in (content: item.content, orderIndex: item.orderIndex) diff --git a/MOUP/MOUP/Presentation/Routine/ViewModel/EditRoutineViewModel.swift b/MOUP/MOUP/Presentation/Routine/ViewModel/EditRoutineViewModel.swift index d939b8b2..d04e7514 100644 --- a/MOUP/MOUP/Presentation/Routine/ViewModel/EditRoutineViewModel.swift +++ b/MOUP/MOUP/Presentation/Routine/ViewModel/EditRoutineViewModel.swift @@ -219,7 +219,7 @@ final class EditRoutineViewModel { validationFocusRelay.accept(.alarmTime) return .empty() } - let alarmTimeString = String(format: "%d:%02d", hour, minute) + let alarmTimeString = String(format: "%02d:%02d", hour, minute) let tasks = validItems.map { item in (content: item.content, orderIndex: item.orderIndex)