diff --git a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift index ad80884..f667e40 100644 --- a/WhereAreYou/WhereAreYou/Core/AppDelegate.swift +++ b/WhereAreYou/WhereAreYou/Core/AppDelegate.swift @@ -22,6 +22,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } FirebaseApp.configure() configureFirebaseEmulators() + DIContainer.shared.registerDependencies() return true } diff --git a/WhereAreYou/WhereAreYou/Core/DI/AppDIContainer+Register.swift b/WhereAreYou/WhereAreYou/Core/DI/AppDIContainer+Register.swift new file mode 100644 index 0000000..b0a9a0e --- /dev/null +++ b/WhereAreYou/WhereAreYou/Core/DI/AppDIContainer+Register.swift @@ -0,0 +1,34 @@ +// +// AppDIContainer+Register.swift +// WhereAreYou +// +// Created by 김성훈 on 8/26/26. +// + +extension DIContainer { + + func registerDependencies() { + // 위치 + let coreLocationRepository = CoreLocationRepository() + register(LocationRepository.self, instance: coreLocationRepository) + register(LocationPermissionRepository.self, instance: coreLocationRepository) + + // 약속 생성/조회 + register(AppointmentCreationRepository.self) { MockAppointmentCreationRepository() } + register(AppointmentDetailRepository.self) { MockAppointmentDetailRepository() } + register(AppointmentInfoRepository.self) { MockAppointmentInfoRepository() } + + // 채팅 + register(ChatRepository.self) { MockChatRepository() } + + // 장소 검색/조회 + register(NearbyPlaceRepository.self) { MockNearbyPlaceRepository() } + register(PlaceSearchRepository.self) { MockPlaceSearchRepository() } + register(ReverseGeocodingRepository.self) { MockReverseGeocodingRepository() } + register(SharedPlaceRepository.self) { MockSharedPlaceRepository() } + + // 길찾기 + register(RouteSearchRepository.self) { MockRouteSearchRepository() } + } + +} diff --git a/WhereAreYou/WhereAreYou/Core/DI/DIContainer.swift b/WhereAreYou/WhereAreYou/Core/DI/DIContainer.swift new file mode 100644 index 0000000..84ed742 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Core/DI/DIContainer.swift @@ -0,0 +1,34 @@ +// +// DIContainer.swift +// WhereAreYou +// +// Created by 김성훈 on 8/26/26. +// + +import Foundation + +/// register/resolve 방식의 DI 컨테이너 +final class DIContainer { + + static let shared = DIContainer() + + private var factories: [ObjectIdentifier: () -> Any] = [:] + + private init() { } + + func register(_ type: T.Type, factory: @escaping () -> T) { + factories[ObjectIdentifier(type)] = factory + } + + func register(_ type: T.Type, instance: T) { + factories[ObjectIdentifier(type)] = { instance } + } + + func resolve(_ type: T.Type = T.self) -> T { + guard let factory = factories[ObjectIdentifier(type)], let resolved = factory() as? T else { + fatalError("\(type)가 DIContainer에 등록되어 있지 않습니다.") + } + return resolved + } + +} diff --git a/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift b/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift index 92c76bb..ca1297c 100644 --- a/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift +++ b/WhereAreYou/WhereAreYou/Data/MockAppointmentDetailRepository.swift @@ -19,7 +19,22 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { } } + func fetchAppointment( + code: String, + completion: @escaping (Result<(appointment: Appointment, currentUserID: String), Error>) -> Void + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + guard code == Self.mockCode else { + completion(.failure(AppError.notFound)) + return + } + let appointment = Self.makeMockAppointment(appointmentID: "appointment_joined") + completion(.success((appointment: appointment, currentUserID: Self.currentUserID))) + } + } + private static let currentUserID = "user_me" + private static let mockCode = "MOCK1234" private static func profileImageURL(_ assetName: String) -> URL { URL(string: "asset://\(assetName)")! @@ -133,7 +148,7 @@ final class MockAppointmentDetailRepository: AppointmentDetailRepository { return Appointment( id: appointmentID, - code: "MOCK1234", + code: Self.mockCode, name: "고기굽는방앗간 이수역점", dateTime: now.addingTimeInterval(13 * 60), place: place, diff --git a/WhereAreYou/WhereAreYou/Domain/Repository/AppointmentDetailRepository.swift b/WhereAreYou/WhereAreYou/Domain/Repository/AppointmentDetailRepository.swift index dee4fc3..06b0992 100644 --- a/WhereAreYou/WhereAreYou/Domain/Repository/AppointmentDetailRepository.swift +++ b/WhereAreYou/WhereAreYou/Domain/Repository/AppointmentDetailRepository.swift @@ -14,4 +14,9 @@ protocol AppointmentDetailRepository { completion: @escaping (Result<(appointment: Appointment, currentUserID: String), Error>) -> Void ) + func fetchAppointment( + code: String, + completion: @escaping (Result<(appointment: Appointment, currentUserID: String), Error>) -> Void + ) + } diff --git a/WhereAreYou/WhereAreYou/Domain/UseCase/JoinAppointmentUseCase.swift b/WhereAreYou/WhereAreYou/Domain/UseCase/JoinAppointmentUseCase.swift new file mode 100644 index 0000000..16100f6 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Domain/UseCase/JoinAppointmentUseCase.swift @@ -0,0 +1,23 @@ +// +// JoinAppointmentUseCase.swift +// WhereAreYou +// +// Created by 김성훈 on 8/26/26. +// + +final class JoinAppointmentUseCase { + + private let repository: AppointmentDetailRepository + + init(repository: AppointmentDetailRepository) { + self.repository = repository + } + + func execute( + code: String, + completion: @escaping (Result<(appointment: Appointment, currentUserID: String), Error>) -> Void + ) { + repository.fetchAppointment(code: code, completion: completion) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentCreation/AppointmentCreationViewController.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentCreation/AppointmentCreationViewController.swift index 51c9585..e4b8c4b 100644 --- a/WhereAreYou/WhereAreYou/Presentation/AppointmentCreation/AppointmentCreationViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentCreation/AppointmentCreationViewController.swift @@ -119,8 +119,8 @@ extension AppointmentCreationViewController { card.onCopyCodeTap = { UIPasteboard.general.string = info.code } - card.onConfirmTap = { - // TODO: 약속 화면으로 이동 + card.onConfirmTap = { [weak self] in + self?.presentChat(appointmentInfo: info) } view.addSubview(card) @@ -141,6 +141,34 @@ extension AppointmentCreationViewController { } } + private func presentChat(appointmentInfo: AppointmentInfo) { + guard let navigationController else { return } + + let chatRepository = DIContainer.shared.resolve(ChatRepository.self) + let chatViewModel = ChatViewModel( + appointmentInfo: appointmentInfo, + fetchMessagesUseCase: FetchMessagesUseCase(repository: chatRepository), + sendMessageUseCase: SendMessageUseCase(repository: chatRepository), + getCurrentLocationUseCase: GetCurrentLocationUseCase( + repository: DIContainer.shared.resolve(LocationRepository.self) + ), + shareLocationUseCase: ShareLocationUseCase(repository: chatRepository), + sharePlaceUseCase: SharePlaceUseCase( + chatRepository: chatRepository, + sharedPlaceRepository: DIContainer.shared.resolve(SharedPlaceRepository.self) + ) + ) + let chatViewController = ChatViewController(viewModel: chatViewModel) + + var viewControllers = navigationController.viewControllers + if let index = viewControllers.firstIndex(of: self) { + viewControllers[index] = chatViewController + } else { + viewControllers.append(chatViewController) + } + navigationController.setViewControllers(viewControllers, animated: true) + } + // MARK: - Date Picker private func presentDatePicker() { @@ -167,7 +195,7 @@ extension AppointmentCreationViewController { let searchPlaceViewController = SearchPlaceCardViewController( viewModel: SearchPlaceCardViewModel( searchPlacesUseCase: SearchPlacesUseCase( - repository: MockPlaceSearchRepository() + repository: DIContainer.shared.resolve(PlaceSearchRepository.self) ) ), selectionButtonTitle: "선택하기", @@ -192,11 +220,11 @@ extension AppointmentCreationViewController { private func presentPlaceMapSelection() { let placeSelectionViewModel = PlaceSelectionViewModel( getCurrentLocationUseCase: GetCurrentLocationUseCase( - repository: CoreLocationRepository() + repository: DIContainer.shared.resolve(LocationRepository.self) ), getNearbyPlaceUseCase: GetNearbyPlaceUseCase( - nearbyPlaceRepository: MockNearbyPlaceRepository(), - reverseGeocodingRepository: MockReverseGeocodingRepository() + nearbyPlaceRepository: DIContainer.shared.resolve(NearbyPlaceRepository.self), + reverseGeocodingRepository: DIContainer.shared.resolve(ReverseGeocodingRepository.self) ), initialCoordinate: viewModel.place?.coordinate ) diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift index ce8b34e..cd02afd 100644 --- a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift @@ -198,11 +198,11 @@ final class AppointmentListViewController: UIViewController { }() let card = AppointmentListCard(item: item, accessoryView: remainingTimeLabel) - card.onChatTap = { - print("\(item.title) 대화 열기") + card.onChatTap = { [weak self] in + self?.presentChat(appointmentID: item.id) } - card.onMapTap = { - print("\(item.title) 지도 열기") + card.onMapTap = { [weak self] in + self?.presentAppointmentRoute(appointmentID: item.id) } card.contextMenuProvider = { [weak self] in self?.makeContextMenu(id: item.id) ?? UIMenu(children: []) @@ -245,4 +245,40 @@ final class AppointmentListViewController: UIViewController { navigationController?.pushViewController(PastAppointmentListViewController(), animated: true) } + // MARK: - Chat / Route + + private func presentChat(appointmentID: String) { + let repository = DIContainer.shared.resolve(AppointmentInfoRepository.self) + FetchAppointmentInfoUseCase(repository: repository).execute(appointmentID: appointmentID) { [weak self] result in + DispatchQueue.main.async { + guard let self, case .success(let appointment) = result else { return } + let chatRepository = DIContainer.shared.resolve(ChatRepository.self) + let viewModel = ChatViewModel( + appointmentInfo: AppointmentInfo(appointment: appointment), + fetchMessagesUseCase: FetchMessagesUseCase(repository: chatRepository), + sendMessageUseCase: SendMessageUseCase(repository: chatRepository), + getCurrentLocationUseCase: GetCurrentLocationUseCase( + repository: DIContainer.shared.resolve(LocationRepository.self) + ), + shareLocationUseCase: ShareLocationUseCase(repository: chatRepository), + sharePlaceUseCase: SharePlaceUseCase( + chatRepository: chatRepository, + sharedPlaceRepository: DIContainer.shared.resolve(SharedPlaceRepository.self) + ) + ) + self.navigationController?.pushViewController(ChatViewController(viewModel: viewModel), animated: true) + } + } + } + + private func presentAppointmentRoute(appointmentID: String) { + let routeViewModel = AppointmentRouteViewModel( + appointmentID: appointmentID, + getAppointmentDetailUseCase: GetAppointmentDetailUseCase( + repository: DIContainer.shared.resolve(AppointmentDetailRepository.self) + ) + ) + present(AppointmentRouteViewController(viewModel: routeViewModel), animated: true) + } + } diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift index fbfd4cf..91ff77c 100644 --- a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift @@ -101,11 +101,11 @@ final class PastAppointmentListViewController: UIViewController { private func addCard(for item: AppointmentListItem) { let card = AppointmentListCard(item: item, accessoryView: nil) - card.onChatTap = { - print("\(item.title) 대화 열기") + card.onChatTap = { [weak self] in + self?.presentChat(appointmentID: item.id) } - card.onMapTap = { - print("\(item.title) 지도 열기") + card.onMapTap = { [weak self] in + self?.presentAppointmentRoute(appointmentID: item.id) } card.contextMenuProvider = { [weak self] in self?.makeContextMenu(id: item.id) ?? UIMenu(children: []) @@ -144,4 +144,40 @@ final class PastAppointmentListViewController: UIViewController { present(alert, animated: true) } + // MARK: - Chat / Route + + private func presentChat(appointmentID: String) { + let repository = DIContainer.shared.resolve(AppointmentInfoRepository.self) + FetchAppointmentInfoUseCase(repository: repository).execute(appointmentID: appointmentID) { [weak self] result in + DispatchQueue.main.async { + guard let self, case .success(let appointment) = result else { return } + let chatRepository = DIContainer.shared.resolve(ChatRepository.self) + let viewModel = ChatViewModel( + appointmentInfo: AppointmentInfo(appointment: appointment), + fetchMessagesUseCase: FetchMessagesUseCase(repository: chatRepository), + sendMessageUseCase: SendMessageUseCase(repository: chatRepository), + getCurrentLocationUseCase: GetCurrentLocationUseCase( + repository: DIContainer.shared.resolve(LocationRepository.self) + ), + shareLocationUseCase: ShareLocationUseCase(repository: chatRepository), + sharePlaceUseCase: SharePlaceUseCase( + chatRepository: chatRepository, + sharedPlaceRepository: DIContainer.shared.resolve(SharedPlaceRepository.self) + ) + ) + self.navigationController?.pushViewController(ChatViewController(viewModel: viewModel), animated: true) + } + } + } + + private func presentAppointmentRoute(appointmentID: String) { + let routeViewModel = AppointmentRouteViewModel( + appointmentID: appointmentID, + getAppointmentDetailUseCase: GetAppointmentDetailUseCase( + repository: DIContainer.shared.resolve(AppointmentDetailRepository.self) + ) + ) + present(AppointmentRouteViewController(viewModel: routeViewModel), animated: true) + } + } diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/AppointmentInfoViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/AppointmentInfoViewController.swift index 49431b3..800b213 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/AppointmentInfoViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/AppointmentInfo/AppointmentInfoViewController.swift @@ -143,7 +143,7 @@ final class AppointmentInfoViewController: UIViewController { let searchPlaceViewController = SearchPlaceCardViewController( viewModel: SearchPlaceCardViewModel( searchPlacesUseCase: SearchPlacesUseCase( - repository: MockPlaceSearchRepository() + repository: DIContainer.shared.resolve(PlaceSearchRepository.self) ) ), selectionButtonTitle: "선택하기", @@ -167,11 +167,11 @@ final class AppointmentInfoViewController: UIViewController { private func presentPlaceMapSelection() { let placeSelectionViewModel = PlaceSelectionViewModel( getCurrentLocationUseCase: GetCurrentLocationUseCase( - repository: CoreLocationRepository() + repository: DIContainer.shared.resolve(LocationRepository.self) ), getNearbyPlaceUseCase: GetNearbyPlaceUseCase( - nearbyPlaceRepository: MockNearbyPlaceRepository(), - reverseGeocodingRepository: MockReverseGeocodingRepository() + nearbyPlaceRepository: DIContainer.shared.resolve(NearbyPlaceRepository.self), + reverseGeocodingRepository: DIContainer.shared.resolve(ReverseGeocodingRepository.self) ), initialCoordinate: viewModel.appointmentInfo?.location?.coordinate ) diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift index 15f42ca..64d8159 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewController.swift @@ -52,7 +52,9 @@ final class ChatViewController: UIViewController { for: indexPath ) as! ChatBubbleCell let spacing = self?.topSpacing(at: indexPath.item) ?? 0 - cell.configure(with: item, topSpacing: spacing) + cell.configure(with: item, topSpacing: spacing) { [weak self] _ in + self?.presentAppointmentRoute() + } return cell } }() @@ -126,6 +128,7 @@ final class ChatViewController: UIViewController { init(viewModel: ChatViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) + hidesBottomBarWhenPushed = true } required init?(coder: NSCoder) { @@ -181,12 +184,21 @@ final class ChatViewController: UIViewController { } @objc private func mapButtonTapped() { - // TODO: 지도 화면으로 전환 + presentAppointmentRoute() + } + + private func presentAppointmentRoute() { + let routeViewModel = AppointmentRouteViewModel( + appointmentID: viewModel.appointmentInfo.id, + getAppointmentDetailUseCase: GetAppointmentDetailUseCase( + repository: DIContainer.shared.resolve(AppointmentDetailRepository.self) + ) + ) + present(AppointmentRouteViewController(viewModel: routeViewModel), animated: true) } @objc private func moreButtonTapped() { - // TODO: DIContainer 도입 시 의존성 주입 방식 변경 - let repository = MockAppointmentInfoRepository() + let repository = DIContainer.shared.resolve(AppointmentInfoRepository.self) let fetchUseCase = FetchAppointmentInfoUseCase(repository: repository) let updateUseCase = UpdateAppointmentInfoUseCase(repository: repository) let appointmentInfoVM = AppointmentInfoViewModel( @@ -314,8 +326,7 @@ final class ChatViewController: UIViewController { } private func handleSearchPlace() { - // TODO: DIContainer 도입 시 의존성 주입 방식 변경 - let searchPlacesUseCase = SearchPlacesUseCase(repository: MockPlaceSearchRepository()) + let searchPlacesUseCase = SearchPlacesUseCase(repository: DIContainer.shared.resolve(PlaceSearchRepository.self)) let searchPlaceCardVM = SearchPlaceCardViewModel(searchPlacesUseCase: searchPlacesUseCase) let shareMeVC = SearchPlaceCardViewController( viewModel: searchPlaceCardVM, @@ -335,8 +346,7 @@ final class ChatViewController: UIViewController { } private func handleViewSharedPlaces() { - // TODO: DIContainer 도입 시 의존성 주입 방식 변경 - let sharedPlaceRepo = MockSharedPlaceRepository() + let sharedPlaceRepo = DIContainer.shared.resolve(SharedPlaceRepository.self) let fetchUseCase = FetchSharedPlacesUseCase(repository: sharedPlaceRepo) let voteUseCase = VotePlaceUseCase(repository: sharedPlaceRepo) let sharedPlacesVM = SharedPlacesViewModel( diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift index afffa10..ba99d68 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/ChatViewModel.swift @@ -143,14 +143,19 @@ private extension ChatViewModel { case .text(let text): bubbleContent = text contentType = .text - case .locationShare: + case .locationShare(let coordinate): bubbleContent = "📌 위치를 공유했어요" - contentType = .locationShare + contentType = .locationShare(coordinate: coordinate) case .placeShare(let place): bubbleContent = place.name let isDuplicate = sharedPlaceIDs.contains(place.id) sharedPlaceIDs.insert(place.id) - contentType = .placeShare(placeName: place.name, placeAddress: place.address, isDuplicate: isDuplicate) + contentType = .placeShare( + placeName: place.name, + placeAddress: place.address, + coordinate: place.coordinate, + isDuplicate: isDuplicate + ) } let bubbleItem = ChatBubbleItem( diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleBase.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleBase.swift index b0cd5b2..ed0ec93 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleBase.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleBase.swift @@ -43,6 +43,9 @@ class ChatBubbleBase: UIView { private(set) var addressLabel: UILabel? private(set) var duplicateLabel: UILabel? + /// "위치 보기" 버튼 탭 시 호출 + var onMapTap: ((Coordinate) -> Void)? + init(item: ChatBubbleItem) { super.init(frame: .zero) @@ -65,11 +68,11 @@ class ChatBubbleBase: UIView { switch item.contentType { case .text: break - case .locationShare: + case .locationShare(let coordinate): bubbleLabel.font = .boldPreferredFont(forTextStyle: .footnote) - actionButton = makeMapButton() + actionButton = makeMapButton(coordinate: coordinate) bubbleContentStack.addArrangedSubview(actionButton!) - case .placeShare(let placeName, let placeAddress, let isDuplicate): + case .placeShare(let placeName, let placeAddress, let coordinate, let isDuplicate): bubbleLabel.text = "📌 " + placeName bubbleLabel.font = .boldPreferredFont(forTextStyle: .callout) addressLabel = UILabel() @@ -84,7 +87,7 @@ class ChatBubbleBase: UIView { duplicateLabel?.textColor = .secondaryLabel bubbleContentStack.addArrangedSubview(duplicateLabel!) } - actionButton = makeMapButton() + actionButton = makeMapButton(coordinate: coordinate) bubbleContentStack.addArrangedSubview(actionButton!) } @@ -96,7 +99,7 @@ class ChatBubbleBase: UIView { fatalError("init(coder:) has not been implemented") } - private func makeMapButton() -> UIButton { + private func makeMapButton(coordinate: Coordinate) -> UIButton { var config = UIButton.Configuration.plain() var attributedTitle = AttributedString("위치 보기") attributedTitle.font = .preferredFont(forTextStyle: .caption1) @@ -108,8 +111,8 @@ class ChatBubbleBase: UIView { let button = UIButton(configuration: config) button.backgroundColor = .white.withAlphaComponent(0.2) button.layer.cornerRadius = 12 - button.addAction(UIAction { _ in - // TODO: 공유된 위치를 중심으로 한 지도 화면 열기 + button.addAction(UIAction { [weak self] _ in + self?.onMapTap?(coordinate) }, for: .touchUpInside) return button } diff --git a/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleCell.swift b/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleCell.swift index a2f25de..5f8a217 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleCell.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Chat/SubComponent/ChatBubbleCell.swift @@ -12,16 +12,21 @@ final class ChatBubbleCell: UICollectionViewCell { static let reuseID = "ChatBubbleCell" - func configure(with item: ChatDisplayItem, topSpacing: CGFloat) { + func configure( + with item: ChatDisplayItem, + topSpacing: CGFloat, + onMapTap: ((Coordinate) -> Void)? = nil + ) { contentView.subviews.forEach { $0.removeFromSuperview() } - let bubbleView: UIView + let bubbleView: ChatBubbleBase switch item { case .myMessage(let bubble): bubbleView = MyChatBubble(item: bubble) case .otherMessage(let bubble, let showProfile): bubbleView = OtherChatBubble(item: bubble, showProfile: showProfile) } + bubbleView.onMapTap = onMapTap bubbleView.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(bubbleView) diff --git a/WhereAreYou/WhereAreYou/Presentation/Home/HomeViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Home/HomeViewController.swift index 620b203..ef77ccf 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Home/HomeViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Home/HomeViewController.swift @@ -180,8 +180,8 @@ final class HomeViewController: UIViewController { let cards = appointments.map { UpcomingAppointmentCard(appointment: $0) } zip(cards, appointments).forEach { card, appointment in - card.onTap = { - print(appointment.title) + card.onTap = { [weak self] in + self?.presentChat(appointmentID: appointment.id) } upcomingStack.addArrangedSubview(card) card.widthAnchor.constraint(equalTo: createButton.widthAnchor).isActive = true @@ -199,7 +199,7 @@ final class HomeViewController: UIViewController { private func presentAppointmentCreation() { let viewModel = AppointmentCreationViewModel( createAppointmentUseCase: CreateAppointmentUseCase( - repository: MockAppointmentCreationRepository() + repository: DIContainer.shared.resolve(AppointmentCreationRepository.self) ) ) let viewController = AppointmentCreationViewController(viewModel) @@ -215,8 +215,8 @@ final class HomeViewController: UIViewController { let sheet = JoinAppointmentSheet() sheet.onCancel = { [weak self] in self?.dismissJoinSheet() } - sheet.onJoin = { code in - print("HomeViewController received join code: \(code)") + sheet.onJoin = { [weak self] code in + self?.joinAppointment(code: code) } overlay.addSubview(sheet) @@ -240,6 +240,64 @@ final class HomeViewController: UIViewController { joinSheetOverlay = nil } + private func joinAppointment(code: String) { + let useCase = JoinAppointmentUseCase( + repository: DIContainer.shared.resolve(AppointmentDetailRepository.self) + ) + useCase.execute(code: code) { [weak self] result in + DispatchQueue.main.async { + guard let self else { return } + switch result { + case .success(let (appointment, _)): + self.dismissJoinSheet() + self.presentChat(appointmentInfo: AppointmentInfo(appointment: appointment)) + case .failure: + self.presentJoinFailureAlert() + } + } + } + } + + private func presentJoinFailureAlert() { + let alert = UIAlertController( + title: "약속을 찾을 수 없어요", + message: "코드를 다시 확인해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } + + // MARK: - Chat + + private func presentChat(appointmentID: String) { + let repository = DIContainer.shared.resolve(AppointmentInfoRepository.self) + FetchAppointmentInfoUseCase(repository: repository).execute(appointmentID: appointmentID) { [weak self] result in + DispatchQueue.main.async { + guard let self, case .success(let appointment) = result else { return } + self.presentChat(appointmentInfo: AppointmentInfo(appointment: appointment)) + } + } + } + + private func presentChat(appointmentInfo: AppointmentInfo) { + let chatRepository = DIContainer.shared.resolve(ChatRepository.self) + let viewModel = ChatViewModel( + appointmentInfo: appointmentInfo, + fetchMessagesUseCase: FetchMessagesUseCase(repository: chatRepository), + sendMessageUseCase: SendMessageUseCase(repository: chatRepository), + getCurrentLocationUseCase: GetCurrentLocationUseCase( + repository: DIContainer.shared.resolve(LocationRepository.self) + ), + shareLocationUseCase: ShareLocationUseCase(repository: chatRepository), + sharePlaceUseCase: SharePlaceUseCase( + chatRepository: chatRepository, + sharedPlaceRepository: DIContainer.shared.resolve(SharedPlaceRepository.self) + ) + ) + navigationController?.pushViewController(ChatViewController(viewModel: viewModel), animated: true) + } + } // MARK: - UIScrollViewDelegate diff --git a/WhereAreYou/WhereAreYou/Presentation/Home/JoinAppointmentSheet.swift b/WhereAreYou/WhereAreYou/Presentation/Home/JoinAppointmentSheet.swift index d6dc843..c84e5d3 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Home/JoinAppointmentSheet.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Home/JoinAppointmentSheet.swift @@ -130,7 +130,7 @@ final class JoinAppointmentSheet: UIView { @objc private func dismissKeyboard() { endEditing(true) } private func pasteTapped() { - print("붙여넣기 tapped") + codeField.text = UIPasteboard.general.string } private func cancelTapped() { @@ -138,7 +138,6 @@ final class JoinAppointmentSheet: UIView { } private func joinTapped() { - print("참여하기 tapped") onJoin?(codeField.text ?? "") } diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewController.swift index 82a6b82..d8c8638 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewController.swift @@ -78,6 +78,9 @@ final class AppointmentRouteViewController: UIViewController { private func bindViewModel() { infoView.onChangeRouteTap = { [weak self] in self?.viewModel.changeRouteTapped() } + viewModel.onChangeRouteTap = { [weak self] destination in + self?.presentRouteSearch(destination: destination) + } viewModel.$placeName .receive(on: DispatchQueue.main) @@ -120,6 +123,24 @@ final class AppointmentRouteViewController: UIViewController { .store(in: &cancellables) } + // MARK: - Route Search + + private func presentRouteSearch(destination: Place?) { + let routeSearchViewModel = RouteSearchViewModel( + searchRoutesUseCase: SearchRoutesUseCase( + repository: DIContainer.shared.resolve(RouteSearchRepository.self) + ), + getCurrentLocationUseCase: GetCurrentLocationUseCase( + repository: DIContainer.shared.resolve(LocationRepository.self) + ) + ) + let routeSearchViewController = RouteSearchViewController( + viewModel: routeSearchViewModel, destination: destination + ) + let navigationController = UINavigationController(rootViewController: routeSearchViewController) + present(navigationController, animated: true) + } + // MARK: - Action private func closeTapped() { diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewModel.swift index aee91dd..c89fe36 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewModel.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/AppointmentRoute/AppointmentRouteViewModel.swift @@ -23,8 +23,12 @@ final class AppointmentRouteViewModel { /// 조회 실패 시 사용자에게 보여줄 에러 메시지 @Published private(set) var errorMessage: String? + /// 경로 변경 버튼 탭 시 호출 + var onChangeRouteTap: ((_ destination: Place?) -> Void)? + private let appointmentID: String private let getAppointmentDetailUseCase: GetAppointmentDetailUseCase + private var place: Place? init(appointmentID: String, getAppointmentDetailUseCase: GetAppointmentDetailUseCase) { self.appointmentID = appointmentID @@ -48,10 +52,11 @@ final class AppointmentRouteViewModel { } func changeRouteTapped() { - print("경로 변경 버튼 탭") + onChangeRouteTap?(place) } private func apply(appointment: Appointment, currentUserID: String) { + place = appointment.place placeName = appointment.place?.name placeCoordinate = appointment.place?.coordinate diff --git a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift index 0fcc8f4..23956c6 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Map/PlaceSelection/PlaceSelectionViewController.swift @@ -244,7 +244,7 @@ final class PlaceSelectionViewController: UIViewController { let searchPlaceViewController = SearchPlaceCardViewController( viewModel: SearchPlaceCardViewModel( searchPlacesUseCase: SearchPlacesUseCase( - repository: MockPlaceSearchRepository() + repository: DIContainer.shared.resolve(PlaceSearchRepository.self) ) ), selectionButtonTitle: "선택하기", diff --git a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift index b361262..28891ba 100644 --- a/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/MyPage/MyPageViewController.swift @@ -13,7 +13,7 @@ final class MyPageViewController: UIViewController { private let viewModel = MyPageViewModel( observeLocationPermissionUseCase: ObserveLocationPermissionUseCase( - repository: CoreLocationRepository() + repository: DIContainer.shared.resolve(LocationPermissionRepository.self) ) ) diff --git a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/ChatBubbleItem.swift b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/ChatBubbleItem.swift index ea81b72..fc0b128 100644 --- a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/ChatBubbleItem.swift +++ b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/ChatBubbleItem.swift @@ -19,11 +19,26 @@ struct ChatBubbleItem: Sendable { let contentType: BubbleContentType /// `nonisolated extension ChatBubbleItem: Hashable`의 `==`에서 비교되므로 - /// 합성 Equatable/Hashable 준수도 nonisolated여야 한다. - nonisolated enum BubbleContentType: Hashable, Sendable { + /// 합성 Equatable 준수도 nonisolated여야 한다. + nonisolated enum BubbleContentType: Sendable { case text - case locationShare - case placeShare(placeName: String, placeAddress: String, isDuplicate: Bool) + case locationShare(coordinate: Coordinate) + case placeShare(placeName: String, placeAddress: String, coordinate: Coordinate, isDuplicate: Bool) + } +} + +nonisolated extension ChatBubbleItem.BubbleContentType: Equatable { + static func == (lhs: ChatBubbleItem.BubbleContentType, rhs: ChatBubbleItem.BubbleContentType) -> Bool { + switch (lhs, rhs) { + case (.text, .text): + return true + case (.locationShare, .locationShare): + return true + case (.placeShare(let lName, let lAddress, _, let lDuplicate), .placeShare(let rName, let rAddress, _, let rDuplicate)): + return lName == rName && lAddress == rAddress && lDuplicate == rDuplicate + default: + return false + } } } diff --git a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift index ebbc643..b016446 100644 --- a/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift +++ b/WhereAreYou/WhereAreYou/Presentation/RouteSearch/RouteSearchViewController.swift @@ -125,6 +125,9 @@ final class RouteSearchViewController: UIViewController { private let initialDeparture: Place? private let initialDestination: Place? + /// 경로 선택 완료 시 호출 + var onRouteSelected: (() -> Void)? + init(viewModel: RouteSearchViewModel, departure: Place? = nil, destination: Place? = nil) { self.viewModel = viewModel self.initialDeparture = departure @@ -142,12 +145,26 @@ final class RouteSearchViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .pointBackground title = "경로 검색" + setUpNavigationBar() setUpLayout() setUpActions() bindViewModel() setUpInitialPlaces() } + private func setUpNavigationBar() { + guard presentingViewController != nil else { return } + let closeItem = UIBarButtonItem( + image: UIImage(systemName: "xmark"), style: .plain, target: self, action: #selector(closeTapped) + ) + closeItem.tintColor = .secondaryLabel + navigationItem.leftBarButtonItem = closeItem + } + + @objc private func closeTapped() { + presentingViewController?.dismiss(animated: true) + } + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) viewModel.setDepartureTime(Date()) @@ -289,8 +306,8 @@ final class RouteSearchViewController: UIViewController { self?.presentTimePicker() }, for: .touchUpInside) - selectRouteButton.addAction(UIAction { _ in - print("경로 선택 tapped") + selectRouteButton.addAction(UIAction { [weak self] _ in + self?.onRouteSelected?() }, for: .touchUpInside) } @@ -384,8 +401,7 @@ final class RouteSearchViewController: UIViewController { placeSearchTarget = target let buttonTitle = target == .departure ? "출발지로" : "도착지로" - // TODO: DIContainer로 대체 - let searchPlacesUseCase = SearchPlacesUseCase(repository: MockPlaceSearchRepository()) + let searchPlacesUseCase = SearchPlacesUseCase(repository: DIContainer.shared.resolve(PlaceSearchRepository.self)) let searchViewModel = SearchPlaceCardViewModel(searchPlacesUseCase: searchPlacesUseCase) let searchVC = SearchPlaceCardViewController( viewModel: searchViewModel,