diff --git a/Common/NotificationHistory/NotificationRecord.swift b/Common/NotificationHistory/NotificationHistoryRecord.swift similarity index 87% rename from Common/NotificationHistory/NotificationRecord.swift rename to Common/NotificationHistory/NotificationHistoryRecord.swift index 99974e1e..4100327b 100644 --- a/Common/NotificationHistory/NotificationRecord.swift +++ b/Common/NotificationHistory/NotificationHistoryRecord.swift @@ -1,5 +1,5 @@ // -// NotificationRecord.swift +// NotificationHistoryRecord.swift // koin // // Created by 홍기정 on 7/6/26. @@ -8,6 +8,8 @@ import SwiftData import Foundation +typealias NotificationHistoryRecord = NotificationRecord + @Model final class NotificationRecord { var body: String diff --git a/Common/NotificationHistory/NotificationHistoryService.swift b/Common/NotificationHistory/NotificationHistoryService.swift index 23304b2c..1ad0d49b 100644 --- a/Common/NotificationHistory/NotificationHistoryService.swift +++ b/Common/NotificationHistory/NotificationHistoryService.swift @@ -9,8 +9,8 @@ import SwiftData import Foundation protocol NotificationHistoryService { - func insert(record: NotificationRecord) async throws - func fetchAll() async throws -> [NotificationRecord] + func insert(record: NotificationHistoryRecord) async throws + func fetchAll() async throws -> [NotificationHistoryRecord] func markAsRead(messageId: String) async throws func markAllAsRead() async throws func delete(messageId: String) async throws @@ -25,13 +25,13 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { // MARK: - Initializer init() { container = try? ModelContainer( - for: NotificationRecord.self, + for: NotificationHistoryRecord.self, configurations: .init(groupContainer: .identifier("group.com.bcsdlab.koin")) ) } // MARK: - Create - func insert(record: NotificationRecord) async throws { + func insert(record: NotificationHistoryRecord) async throws { guard let container else { throw SwiftDataError.loadIssueModelContainer } @@ -48,7 +48,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } // MARK: - Read - func fetchAll() async throws -> [NotificationRecord] { + func fetchAll() async throws -> [NotificationHistoryRecord] { guard let container else { throw SwiftDataError.loadIssueModelContainer } @@ -60,7 +60,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } return try await MainActor.run { - var descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.createdAt, order: .reverse)] ) descriptor.fetchLimit = .max @@ -76,7 +76,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } try await MainActor.run { - var descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { notification in notification.messageId == messageId } @@ -97,7 +97,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } try await MainActor.run { - var descriptor = FetchDescriptor() + var descriptor = FetchDescriptor() descriptor.fetchLimit = .max try container.mainContext.enumerate(descriptor) { notification in notification.isRead = true @@ -115,7 +115,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } try await MainActor.run { - try container.mainContext.delete(model: NotificationRecord.self, where: #Predicate { notification in + try container.mainContext.delete(model: NotificationHistoryRecord.self, where: #Predicate { notification in notification.messageId == messageId }) try container.mainContext.save() @@ -130,7 +130,7 @@ final class DefaultNotificationHistoryService: NotificationHistoryService { } try await MainActor.run { - try container.mainContext.delete(model: NotificationRecord.self) + try container.mainContext.delete(model: NotificationHistoryRecord.self) try container.mainContext.save() } @@ -149,7 +149,7 @@ extension DefaultNotificationHistoryService { } try await MainActor.run { - try container.mainContext.delete(model: NotificationRecord.self, where: #Predicate { notification in + try container.mainContext.delete(model: NotificationHistoryRecord.self, where: #Predicate { notification in notification.createdAt < expirationDate }) try container.mainContext.save() diff --git a/Koin/Apps/SceneDelegate.swift b/Koin/Apps/SceneDelegate.swift index cdd85794..6d1c01f9 100644 --- a/Koin/Apps/SceneDelegate.swift +++ b/Koin/Apps/SceneDelegate.swift @@ -137,8 +137,8 @@ extension SceneDelegate { case .chat: if let articleId = Int(parsedQuery["articleId"]), let chatRoomId = Int(parsedQuery["chatRoomId"]) { - let viewModel = ChatViewModel(articleId: articleId, chatRoomId: chatRoomId, articleTitle: nil) - let chatViewController = ChatViewController(viewModel: viewModel) + let viewModel = LostItemChatViewModel(articleId: articleId, chatRoomId: chatRoomId, articleTitle: nil) + let chatViewController = LostItemChatViewController(viewModel: viewModel) navigationController?.pushViewController(chatViewController, animated: true) } case .callvan: @@ -236,8 +236,12 @@ extension SceneDelegate { } private func makeCategoryHostingController() -> UIViewController { + let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) - let categoryRootView = CategoryView(viewModel: CategoryViewModel(logAnalyticsEventUseCase: logAnalyticsEventUseCase)) + let categoryRootView = CategoryView( + viewModel: CategoryViewModel( + checkLoginUseCase: checkLoginUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase)) return CategoryHostingController(rootView: categoryRootView) } @@ -369,13 +373,13 @@ extension SceneDelegate { private func makeLostItemData(lostItemId: Int) -> UIViewController { let userRepository = DefaultUserRepository(service: DefaultUserService()) let lostItemRepository = DefaultLostItemRepository(service: DefaultLostItemService()) - let chatRepository = DefaultChatRepository(service: DefaultChatService()) + let chatRepository = DefaultLostItemRepository(service: DefaultLostItemService()) let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: userRepository) let fetchLostItemDataUseCase = DefaultFetchLostItemDataUseCase(repository: lostItemRepository) let fetchLostItemListUseCase = DefaultFetchLostItemListUseCase(repository: lostItemRepository) let changeLostItemStateUseCase = DefaultChangeLostItemStateUseCase(repository: lostItemRepository) let deleteLostItemUseCase = DefaultDeleteLostItemUseCase(repository: lostItemRepository) - let createChatRoomUseCase = DefaultCreateChatRoomUseCase(chatRepository: chatRepository) + let createChatRoomUseCase = DefaultLostItemCreateChatRoomUseCase(chatRepository: chatRepository) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = LostItemDataViewModel( checkLoginUseCase: checkLoginUseCase, diff --git a/Koin/ColorSystem.swift b/Koin/ColorSystem.swift deleted file mode 100644 index 9a6e1389..00000000 --- a/Koin/ColorSystem.swift +++ /dev/null @@ -1,277 +0,0 @@ -// -// ColorSystem.swift -// koin -// -// Created by 이은지 on 10/16/25. -// - -import UIKit - -extension UIColor { - - enum ColorSystem { - - // MARK: - Neutral - - enum Neutral { - static let gray0 = UIColor(hexCode: "FFFFFF") - static let gray50 = UIColor(hexCode: "FAFAFA") - static let gray100 = UIColor(hexCode: "F5F5F5") - static let gray200 = UIColor(hexCode: "EEEEEE") - static let gray300 = UIColor(hexCode: "E6E6E6") - static let gray400 = UIColor(hexCode: "D9D9D9") - static let gray500 = UIColor(hexCode: "A8A8A8") - static let gray600 = UIColor(hexCode: "6f6f6f") - static let gray700 = UIColor(hexCode: "4B4B4B") - static let gray800 = UIColor(hexCode: "323232") - static let gray900 = UIColor(hexCode: "1F1F1F") - } - - // MARK: - Danger - - enum Danger { - static let red100 = UIColor(hexCode: "FBEEEE") - static let red200 = UIColor(hexCode: "F5D1D1") - static let red300 = UIColor(hexCode: "F0B7B7") - static let red400 = UIColor(hexCode: "E99696") - static let red500 = UIColor(hexCode: "E37878") - static let red600 = UIColor(hexCode: "DE5F5F") - static let red700 = UIColor(hexCode: "D94A4A") - static let red800 = UIColor(hexCode: "D63939") - static let red900 = UIColor(hexCode: "C92A2A") - static let red1000 = UIColor(hexCode: "B12525") - static let red1100 = UIColor(hexCode: "982020") - static let red1200 = UIColor(hexCode: "871C1C") - static let red1300 = UIColor(hexCode: "721818") - static let red1400 = UIColor(hexCode: "541212") - } - - // MARK: - SubColor - - enum SubColor { - static let orange100 = UIColor(hexCode: "FEF2D1") - static let orange200 = UIColor(hexCode: "FEE1A4") - static let orange300 = UIColor(hexCode: "FED98B") - static let orange400 = UIColor(hexCode: "FCCC77") - static let orange500 = UIColor(hexCode: "FAB655") - static let orange600 = UIColor(hexCode: "F9AE43") - static let orange700 = UIColor(hexCode: "F7941E") - static let orange800 = UIColor(hexCode: "D47415") - static let orange900 = UIColor(hexCode: "B1580F") - static let orange1000 = UIColor(hexCode: "A4470D") - static let orange1100 = UIColor(hexCode: "8F3F09") - static let orange1200 = UIColor(hexCode: "7D3708") - static let orange1300 = UIColor(hexCode: "682C06") - static let orange1400 = UIColor(hexCode: "532004") - } - - // MARK: - Warning - - enum Warning { - static let yellow100 = UIColor(hexCode: "FDF3E2") - static let yellow200 = UIColor(hexCode: "FBEACC") - static let yellow300 = UIColor(hexCode: "F9DFB3") - static let yellow400 = UIColor(hexCode: "F6D38F") - static let yellow500 = UIColor(hexCode: "F3C873") - static let yellow600 = UIColor(hexCode: "F1BD5E") - static let yellow700 = UIColor(hexCode: "EDB345") - static let yellow800 = UIColor(hexCode: "E8A62A") - static let yellow900 = UIColor(hexCode: "DD9616") - static let yellow1000 = UIColor(hexCode: "C38312") - static let yellow1100 = UIColor(hexCode: "AF740F") - static let yellow1200 = UIColor(hexCode: "845B0D") - static let yellow1300 = UIColor(hexCode: "5E4209") - static let yellow1400 = UIColor(hexCode: "3D2A06") - } - - // MARK: - Chartreuse - - enum Chartreuse { - static let chartreuse100 = UIColor(hexCode: "DBFC6E") - static let chartreuse200 = UIColor(hexCode: "CBF443") - static let chartreuse300 = UIColor(hexCode: "BCE92A") - static let chartreuse400 = UIColor(hexCode: "AAD816") - static let chartreuse500 = UIColor(hexCode: "98C50A") - static let chartreuse600 = UIColor(hexCode: "87B103") - static let chartreuse700 = UIColor(hexCode: "769C00") - static let chartreuse800 = UIColor(hexCode: "678800") - static let chartreuse900 = UIColor(hexCode: "577400") - static let chartreuse1000 = UIColor(hexCode: "486000") - static let chartreuse1100 = UIColor(hexCode: "3A4D00") - static let chartreuse1200 = UIColor(hexCode: "2C3B00") - static let chartreuse1300 = UIColor(hexCode: "212C00") - static let chartreuse1400 = UIColor(hexCode: "181F00") - } - - // MARK: - Celery - - enum Celery { - static let celery100 = UIColor(hexCode: "CDFCBF") - static let celery200 = UIColor(hexCode: "AEF69D") - static let celery300 = UIColor(hexCode: "96EE85") - static let celery400 = UIColor(hexCode: "72E06A") - static let celery500 = UIColor(hexCode: "4ECF50") - static let celery600 = UIColor(hexCode: "27BB36") - static let celery700 = UIColor(hexCode: "07A721") - static let celery800 = UIColor(hexCode: "009112") - static let celery900 = UIColor(hexCode: "007C0F") - static let celery1000 = UIColor(hexCode: "00670F") - static let celery1100 = UIColor(hexCode: "00530D") - static let celery1200 = UIColor(hexCode: "00400A") - static let celery1300 = UIColor(hexCode: "003007") - static let celery1400 = UIColor(hexCode: "002205") - } - - // MARK: - Success - - enum Success { - static let green100 = UIColor(hexCode: "E5F4EC") - static let green200 = UIColor(hexCode: "D0EBDD") - static let green300 = UIColor(hexCode: "BAE9D1") - static let green400 = UIColor(hexCode: "A8E3C6") - static let green500 = UIColor(hexCode: "93DCB8") - static let green600 = UIColor(hexCode: "50CE83") - static let green700 = UIColor(hexCode: "36BF6E") - static let green800 = UIColor(hexCode: "2DA05C") - static let green900 = UIColor(hexCode: "288F52") - static let green1000 = UIColor(hexCode: "228149") - static let green1100 = UIColor(hexCode: "1F7743") - static let green1200 = UIColor(hexCode: "196137") - static let green1300 = UIColor(hexCode: "15512E") - static let green1400 = UIColor(hexCode: "0F3920") - } - - // MARK: - Seafoam - - enum Seafoam { - static let seafoam100 = UIColor(hexCode: "CEF7F3") - static let seafoam200 = UIColor(hexCode: "AAF1EA") - static let seafoam300 = UIColor(hexCode: "8CE9E2") - static let seafoam400 = UIColor(hexCode: "65DAD2") - static let seafoam500 = UIColor(hexCode: "3FC9C1") - static let seafoam600 = UIColor(hexCode: "0FB5AE") - static let seafoam700 = UIColor(hexCode: "00A19A") - static let seafoam800 = UIColor(hexCode: "008C87") - static let seafoam900 = UIColor(hexCode: "007772") - static let seafoam1000 = UIColor(hexCode: "00635F") - static let seafoam1100 = UIColor(hexCode: "0C4F4C") - static let seafoam1200 = UIColor(hexCode: "123C3A") - static let seafoam1300 = UIColor(hexCode: "122C2B") - static let seafoam1400 = UIColor(hexCode: "0F1F1E") - } - - // MARK: - Cyan - - enum Cyan { - static let cyan100 = UIColor(hexCode: "C5F8FF") - static let cyan200 = UIColor(hexCode: "A4F0FF") - static let cyan300 = UIColor(hexCode: "88E7FA") - static let cyan400 = UIColor(hexCode: "60D8F3") - static let cyan500 = UIColor(hexCode: "33C5E8") - static let cyan600 = UIColor(hexCode: "12B0DA") - static let cyan700 = UIColor(hexCode: "019CC8") - static let cyan800 = UIColor(hexCode: "0086B4") - static let cyan900 = UIColor(hexCode: "00719F") - static let cyan1000 = UIColor(hexCode: "005D89") - static let cyan1100 = UIColor(hexCode: "004A73") - static let cyan1200 = UIColor(hexCode: "00395D") - static let cyan1300 = UIColor(hexCode: "002A46") - static let cyan1400 = UIColor(hexCode: "001E33") - } - - // MARK: - Info - - enum Info { - static let blue100 = UIColor(hexCode: "F8FCFF") - static let blue200 = UIColor(hexCode: "D6EEFF") - static let blue300 = UIColor(hexCode: "CCE5FF") - static let blue400 = UIColor(hexCode: "BDDDFF") - static let blue500 = UIColor(hexCode: "A8D2FF") - static let blue600 = UIColor(hexCode: "75B7FF") - static let blue700 = UIColor(hexCode: "3394FF") - static let blue800 = UIColor(hexCode: "1A87FF") - static let blue900 = UIColor(hexCode: "195BE6") - static let blue1000 = UIColor(hexCode: "1A55CF") - static let blue1100 = UIColor(hexCode: "1B4FBB") - static let blue1200 = UIColor(hexCode: "1947A9") - static let blue1300 = UIColor(hexCode: "143885") - static let blue1400 = UIColor(hexCode: "0D2559") - } - - // MARK: - Indigo - - enum Indigo { - static let indigo100 = UIColor(hexCode: "EDEEFF") - static let indigo200 = UIColor(hexCode: "E0E2FF") - static let indigo300 = UIColor(hexCode: "D3D5FF") - static let indigo400 = UIColor(hexCode: "C1C4FF") - static let indigo500 = UIColor(hexCode: "ACAFFF") - static let indigo600 = UIColor(hexCode: "9599FF") - static let indigo700 = UIColor(hexCode: "7E84FC") - static let indigo800 = UIColor(hexCode: "686DF4") - static let indigo900 = UIColor(hexCode: "5258E4") - static let indigo1000 = UIColor(hexCode: "4046CA") - static let indigo1100 = UIColor(hexCode: "3236A8") - static let indigo1200 = UIColor(hexCode: "262986") - static let indigo1300 = UIColor(hexCode: "1B1E64") - static let indigo1400 = UIColor(hexCode: "141648") - } - - // MARK: - Primary - - enum Primary { - static let purple100 = UIColor(hexCode: "F5EBFF") - static let purple200 = UIColor(hexCode: "DDB1FE") - static let purple300 = UIColor(hexCode: "D39AFE") - static let purple400 = UIColor(hexCode: "CE86FD") - static let purple500 = UIColor(hexCode: "C969FC") - static let purple600 = UIColor(hexCode: "C358FC") - static let purple700 = UIColor(hexCode: "B611F5") - static let purple800 = UIColor(hexCode: "980AC9") - static let purple900 = UIColor(hexCode: "7D08A4") - static let purple1000 = UIColor(hexCode: "6F09A2") - static let purple1100 = UIColor(hexCode: "600481") - static let purple1200 = UIColor(hexCode: "550472") - static let purple1300 = UIColor(hexCode: "44025E") - static let purple1400 = UIColor(hexCode: "2F0141") - } - - // MARK: - Fuchsia - - enum Fuchsia { - static let fuchsia100 = UIColor(hexCode: "FFE9FC") - static let fuchsia200 = UIColor(hexCode: "FFDAFA") - static let fuchsia300 = UIColor(hexCode: "FEC7F8") - static let fuchsia400 = UIColor(hexCode: "FBAEF6") - static let fuchsia500 = UIColor(hexCode: "F592F3") - static let fuchsia600 = UIColor(hexCode: "ED74ED") - static let fuchsia700 = UIColor(hexCode: "E055E2") - static let fuchsia800 = UIColor(hexCode: "CD3ACE") - static let fuchsia900 = UIColor(hexCode: "B622B7") - static let fuchsia1000 = UIColor(hexCode: "9D039E") - static let fuchsia1100 = UIColor(hexCode: "800081") - static let fuchsia1200 = UIColor(hexCode: "640664") - static let fuchsia1300 = UIColor(hexCode: "470E46") - static let fuchsia1400 = UIColor(hexCode: "320D31") - } - - // MARK: - Magenta - - enum Magenta { - static let magenta100 = UIColor(hexCode: "FFEAF1") - static let magenta200 = UIColor(hexCode: "FFDCE8") - static let magenta300 = UIColor(hexCode: "FFCADD") - static let magenta400 = UIColor(hexCode: "FFB2CE") - static let magenta500 = UIColor(hexCode: "FF95BD") - static let magenta600 = UIColor(hexCode: "FA77AA") - static let magenta700 = UIColor(hexCode: "EF5A98") - static let magenta800 = UIColor(hexCode: "DE3D82") - static let magenta900 = UIColor(hexCode: "C82269") - static let magenta1000 = UIColor(hexCode: "AD0955") - static let magenta1100 = UIColor(hexCode: "8E0045") - static let magenta1200 = UIColor(hexCode: "700037") - static let magenta1300 = UIColor(hexCode: "54032A") - static let magenta1400 = UIColor(hexCode: "3C061D") - } - } -} diff --git a/Koin/Core/Extensions/Asset/ImageAsset.swift b/Koin/Core/Extensions/Asset/ImageAsset.swift index c4e6acd1..18714587 100644 --- a/Koin/Core/Extensions/Asset/ImageAsset.swift +++ b/Koin/Core/Extensions/Asset/ImageAsset.swift @@ -208,6 +208,7 @@ public enum ImageAsset: String { case lostItemDelete // MARK: - Notice + case noticeAISummary case noticeLoginToolTip case noticeManageKeyword case noticeNotLoginToolTip @@ -255,9 +256,11 @@ public enum ImageAsset: String { // MARK: - Home case categoryBusiness + case categoryRecruit case categoryBusSearch case categoryBusTimetable case categoryCallVan + case categoryChat case categoryDepartment case categoryDining case categoryFacility diff --git a/Koin/Core/Extensions/Common/String+.swift b/Koin/Core/Extensions/Common/String+.swift index 3418e1ce..e226f405 100644 --- a/Koin/Core/Extensions/Common/String+.swift +++ b/Koin/Core/Extensions/Common/String+.swift @@ -10,7 +10,7 @@ import Kingfisher import SwiftSoup extension String { - func toChatDateInfo() -> ChatDateInfo { + func toLostItemChatDateInfo() -> LostItemChatDateInfo { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.timeZone = TimeZone(secondsFromGMT: 0) // ✅ UTC 그대로 변환 @@ -31,7 +31,7 @@ extension String { // ✅ 문자열을 Date 타입으로 변환 (UTC 기준) guard let date = formatter.date(from: formattedDateString) else { print("❌ 변환 실패: \(formattedDateString)") - return ChatDateInfo( + return LostItemChatDateInfo( year: 0, month: 0, day: 0, hour: 0, minute: 0, second: 0, isToday: false, isYesterday: false, showingText: "날짜 오류" ) @@ -81,7 +81,7 @@ extension String { } - return ChatDateInfo( + return LostItemChatDateInfo( year: components.year ?? 0, month: components.month ?? 0, day: components.day ?? 0, diff --git a/Koin/Core/View/BottomSheetViewControllerB.swift b/Koin/Core/View/BottomSheetViewControllerB.swift index 524038ef..dee6efb6 100644 --- a/Koin/Core/View/BottomSheetViewControllerB.swift +++ b/Koin/Core/View/BottomSheetViewControllerB.swift @@ -17,40 +17,41 @@ protocol BottomSheetViewControllerBDelegate: AnyObject { final class BottomSheetViewControllerB: UIViewController { // MARK: - Properties - private var contentViewBottomConstraint: Constraint? - private var safeAreaHeightConstraint: Constraint? - private var alpha: CGFloat + private var dismissContentViewConstraint: Constraint? + private var presentContentViewConstraint: Constraint? + private let dimAlpha: CGFloat // MARK: - UI Components private let dimView = UIView().then { $0.alpha = 0 } private let contentView: UIView private let safeAreaView = UIView() + // MARK: - LayoutGuide + private let safeAreaLayoutGuide = UILayoutGuide() + // MARK: - Initializer - init(contentView: UIView, dimColor: UIColor, dimAlpha: CGFloat, backgroundColor: UIColor) { + init( + contentView: UIView, + dimAlpha: CGFloat = 0.7 + ) { self.contentView = contentView - self.alpha = dimAlpha + self.dimAlpha = dimAlpha super.init(nibName: nil, bundle: nil) - modalTransitionStyle = .crossDissolve modalPresentationStyle = .overFullScreen dimView.do { - $0.backgroundColor = dimColor + $0.backgroundColor = .appColor(.neutral800) } safeAreaView.do { - $0.backgroundColor = backgroundColor + $0.backgroundColor = contentView.backgroundColor } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - NotificationCenter.default.removeObserver(self) - } - // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() @@ -61,7 +62,6 @@ final class BottomSheetViewControllerB: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - safeAreaHeightConstraint?.update(offset: view.safeAreaInsets.bottom) present() } } @@ -69,17 +69,22 @@ final class BottomSheetViewControllerB: UIViewController { extension BottomSheetViewControllerB: BottomSheetViewControllerBDelegate { func present() { -// view.layoutIfNeeded() - contentViewBottomConstraint?.update(offset: 0) + view.layoutIfNeeded() + + dismissContentViewConstraint?.deactivate() + presentContentViewConstraint?.activate() + UIView.animate(withDuration: 0.25) { [weak self] in guard let self else { return } - dimView.alpha = alpha + dimView.alpha = dimAlpha view.layoutIfNeeded() } } func dismiss() { - contentViewBottomConstraint?.update(offset: contentView.bounds.height) + dismissContentViewConstraint?.activate() + presentContentViewConstraint?.deactivate() + UIView.animate( withDuration: 0.25, animations: { [weak self] in @@ -94,7 +99,6 @@ extension BottomSheetViewControllerB: BottomSheetViewControllerBDelegate { } extension BottomSheetViewControllerB { - private func setGesture() { let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimViewTapped)) dimView.addGestureRecognizer(tapGesture) @@ -107,7 +111,6 @@ extension BottomSheetViewControllerB { } extension BottomSheetViewControllerB { - private func configureView() { setUpLayouts() setUpConstraints() @@ -117,6 +120,9 @@ extension BottomSheetViewControllerB { [dimView, contentView, safeAreaView].forEach { view.addSubview($0) } + [safeAreaLayoutGuide].forEach { + view.addLayoutGuide($0) + } } private func setUpConstraints() { @@ -124,14 +130,22 @@ extension BottomSheetViewControllerB { $0.edges.equalToSuperview() } contentView.snp.makeConstraints { - contentView.layoutIfNeeded() - contentViewBottomConstraint = $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top).offset(contentView.bounds.height).constraint + $0.height.lessThanOrEqualTo(view.safeAreaLayoutGuide.snp.height) + $0.leading.trailing.equalToSuperview() + dismissContentViewConstraint = $0.top.equalTo(view.snp.bottom).constraint + presentContentViewConstraint = $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top).constraint + } + presentContentViewConstraint?.deactivate() + + safeAreaLayoutGuide.snp.makeConstraints { $0.leading.trailing.equalToSuperview() + $0.top.equalTo(view.keyboardLayoutGuide.snp.top) + $0.bottom.equalToSuperview() } safeAreaView.snp.makeConstraints { $0.leading.trailing.equalToSuperview() $0.top.equalTo(contentView.snp.bottom) - safeAreaHeightConstraint = $0.height.equalTo(0).constraint + $0.height.equalTo(safeAreaLayoutGuide.snp.height) } } } diff --git a/Koin/Core/View/KoinDropdown/Helper/KoinDropdownAnimator.swift b/Koin/Core/View/KoinDropdown/Helper/KoinDropdownAnimator.swift new file mode 100644 index 00000000..9b38f73f --- /dev/null +++ b/Koin/Core/View/KoinDropdown/Helper/KoinDropdownAnimator.swift @@ -0,0 +1,56 @@ +// +// KoinDropdownAnimator.swift +// koin +// +// Created by 홍기정 on 8/21/26. +// + +import UIKit + +@MainActor +final class KoinDropdownAnimator { + + enum Metric { + static let presentDuration: TimeInterval = 0.3 + static let presentBounce: CGFloat = 0.15 + static let dismissDuration: TimeInterval = 0.2 + } + + private func hiddenTransform(travel: CGFloat) -> CGAffineTransform { + CGAffineTransform(translationX: 0, y: -travel) + } + + func present( + view: UIView, + travel: CGFloat, + completion: (() -> Void)? = nil + ) { + view.isHidden = false + view.alpha = 0 + view.transform = hiddenTransform(travel: travel) + + UIView.animate( + springDuration: Metric.presentDuration, + bounce: Metric.presentBounce + ) { + view.alpha = 1 + view.transform = .identity + } completion: { _ in + completion?() + } + } + + func dismiss( + view: UIView, + travel: CGFloat, + completion: (() -> Void)? = nil + ) { + UIView.animate(springDuration: Metric.dismissDuration) { + view.alpha = 0 + view.transform = self.hiddenTransform(travel: travel) + } completion: { _ in + view.isHidden = true + completion?() + } + } +} diff --git a/Koin/Core/View/KoinDropdown/KoinDropdown.swift b/Koin/Core/View/KoinDropdown/KoinDropdown.swift new file mode 100644 index 00000000..400beca9 --- /dev/null +++ b/Koin/Core/View/KoinDropdown/KoinDropdown.swift @@ -0,0 +1,146 @@ +// +// KoinDropdown.swift +// koin +// +// Created by 홍기정 on 8/21/26. +// + +import UIKit +import Combine + +@MainActor +protocol KoinDropdownContentView: AnyObject { + var dismissTappedPublisher: AnyPublisher { get } + var height: CGFloat { get } +} + +@MainActor +final class KoinDropdown: UIView { + + // MARK: - Properties + private weak var host: KoinDropdownHost? + private(set) weak var trigger: UIView? + private let configuration: KoinDropdownConfiguration + private var subscriptions: Set = [] + + // MARK: - UI Components + private let contentView: UIView & KoinDropdownContentView + + // MARK: - Animation + let animator = KoinDropdownAnimator() + private(set) var travel: CGFloat = 0 + + // MARK: - Initializer + init( + host: KoinDropdownHost, + trigger: UIView, + contentView: UIView & KoinDropdownContentView, + configuration: KoinDropdownConfiguration + ) { + self.host = host + self.trigger = trigger + self.contentView = contentView + self.configuration = configuration + super.init(frame: .zero) + + configureView() + bind() + } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Bind + private func bind() { + contentView.dismissTappedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.dismiss() + } + .store(in: &subscriptions) + } + + // MARK: - Public + func toggle() { + host?.toggle(self) + } + + func present() { + host?.present(self) + } + + func dismiss() { + host?.dismiss(self) + } +} + + +extension KoinDropdown { + func layout(in space: UIView) -> Bool { + guard let trigger else { return false } + contentView.transform = .identity + + let triggerFrame = trigger.convert(trigger.bounds, to: space) + let panelHeight = contentView.height + let padding = configuration.shadowPadding + + guard 0 < triggerFrame.width, + 0 < panelHeight else { + return false + } + self.frame = CGRect( + x: triggerFrame.minX - padding.left, + y: triggerFrame.maxY, + width: triggerFrame.width + padding.left + padding.right, + height: configuration.topPadding + panelHeight + padding.bottom + ) + contentView.frame = CGRect( + x: padding.left, + y: configuration.topPadding, + width: triggerFrame.width, + height: panelHeight + ) + applyShadow() + + travel = panelHeight + configuration.topPadding + return true + } + + + func animatePresent() { + animator.present(view: contentView, travel: travel) + } + + func animateDismiss(completion: @escaping () -> Void) { + animator.dismiss( + view: contentView, + travel: travel, + completion: completion + ) + } +} + +extension KoinDropdown { + /// 프레임이 확정된 뒤에 부른다 — `shadowPath` 가 콘텐츠의 bounds 를 쓰기 때문이다. + private func applyShadow() { + let shadow = configuration.shadow + contentView.layer.applySketchShadow( + color: UIColor.appColor(shadow.color), + alpha: shadow.alpha, + x: shadow.offset.x, + y: shadow.offset.y, + blur: shadow.blur, + spread: shadow.spread + ) + } +} + + +extension KoinDropdown { + private func configureView() { + clipsToBounds = true + backgroundColor = .clear + addSubview(contentView) + } +} diff --git a/Koin/Core/View/KoinDropdown/KoinDropdownConfiguration.swift b/Koin/Core/View/KoinDropdown/KoinDropdownConfiguration.swift new file mode 100644 index 00000000..f8268de3 --- /dev/null +++ b/Koin/Core/View/KoinDropdown/KoinDropdownConfiguration.swift @@ -0,0 +1,65 @@ +// +// KoinDropdownConfiguration.swift +// koin +// +// Created by 홍기정 on 8/21/26. +// + +import UIKit + +struct KoinDropdownConfiguration { + let topPadding: CGFloat + let shadow: Shadow +} + +extension KoinDropdownConfiguration { + enum Shadow { + case shadow2 + case shadowSmall + + var color: ColorAsset { + switch self { + case .shadow2, .shadowSmall: + return .neutral800 + } + } + + var alpha: Float { + switch self { + case .shadow2: return 0.08 + case .shadowSmall: return 0.06 + } + } + + var offset: CGPoint { + switch self { + case .shadow2: return CGPoint(x: 0, y: 4) + case .shadowSmall: return CGPoint(x: 0, y: 1) + } + } + + var blur: CGFloat { + switch self { + case .shadow2: return 10 + case .shadowSmall: return 9 + } + } + + var spread: CGFloat { + switch self { + case .shadow2: return 0 + case .shadowSmall: return 1 + } + } + } + + var shadowPadding: UIEdgeInsets { + let base = max(0, shadow.blur + shadow.spread) + return UIEdgeInsets( + top: 0, + left: base + max(0, -shadow.offset.x), + bottom: base + max(0, shadow.offset.y), + right: base + max(0, shadow.offset.x) + ) + } +} diff --git a/Koin/Core/View/KoinDropdown/KoinDropdownHost.swift b/Koin/Core/View/KoinDropdown/KoinDropdownHost.swift new file mode 100644 index 00000000..44179ffa --- /dev/null +++ b/Koin/Core/View/KoinDropdown/KoinDropdownHost.swift @@ -0,0 +1,191 @@ +// +// KoinDropdownHost.swift +// koin +// +// Created by 홍기정 on 8/22/26. +// + +import UIKit + +@MainActor +final class KoinDropdownHost { + + private enum Metric { + static let overlayZPosition: CGFloat = 10_000 + } + + // MARK: - Properties + private weak var scrollView: UIScrollView? + private var overlay: UIView? + + private var presentedDropdown: KoinDropdown? + private var addedBottomInset: CGFloat = 0 + + var isPresenting: Bool { + presentedDropdown != nil + } + + // MARK: - Initializer + init(scrollView: UIScrollView) { + self.scrollView = scrollView + } + + // MARK: - Public + func makeDropdown( + trigger: UIView, + contentView: UIView & KoinDropdownContentView, + configuration: KoinDropdownConfiguration + ) -> KoinDropdown { + return KoinDropdown( + host: self, + trigger: trigger, + contentView: contentView, + configuration: configuration + ) + } +} + +extension KoinDropdownHost { + + // MARK: - Toggle + func toggle(_ dropdown: KoinDropdown) { + if presentedDropdown === dropdown { + dismiss(dropdown) + } else { + present(dropdown) + } + } + + // MARK: - Present + func present(_ dropdown: KoinDropdown) { + guard let scrollView, + presentedDropdown == nil else { + return + } + + scrollView.layoutIfNeeded() + + // 바깥 영역 탭을 인식할 overlay + let overlay = makeOverlay(in: scrollView, dropdown: dropdown) + scrollView.addSubview(overlay) + self.overlay = overlay + + // overlay에 들어가는 dropdown + guard dropdown.layout(in: overlay) else { + overlay.removeFromSuperview() + self.overlay = nil + return + } + overlay.addSubview(dropdown) + self.presentedDropdown = dropdown + + // present 중에는 사용자에 의한 스크롤을 막는다. + scrollView.isScrollEnabled = false + + // present + addedBottomInset = addBottomInset( + travel: dropdown.travel, + of: dropdown, + in: scrollView + ) + scrollView.scrollRectToVisible( + dropdown.convert(dropdown.bounds, to: scrollView), + animated: true + ) + dropdown.animatePresent() + } + + // MARK: - Dismiss + func dismissPresented() { + guard let presentedDropdown else { + return + } + dismiss(presentedDropdown) + } + + func dismiss(_ dropdown: KoinDropdown) { + guard presentedDropdown === dropdown else { + return + } + + let dismissDuration = KoinDropdownAnimator.Metric.dismissDuration + UIView.animate(withDuration: dismissDuration) { [weak self] in + self?.scrollView?.contentInset.bottom -= self?.addedBottomInset ?? 0 + self?.addedBottomInset = 0 + } + + dropdown.animateDismiss { [weak self] in + self?.overlay?.removeFromSuperview() + self?.finishDismiss(of: dropdown) + } + } +} + +extension KoinDropdownHost { + private func makeOverlay( + in scrollView: UIScrollView, + dropdown: KoinDropdown + ) -> UIView { + let scrollableRect = CGRect(origin: .zero, size: scrollView.contentSize) + .union(CGRect(origin: scrollView.contentOffset, size: scrollView.bounds.size)) + + let overlay = UIView(frame: scrollableRect.insetBy( + dx: -scrollView.bounds.width, + dy: -scrollView.bounds.height + )) + overlay.backgroundColor = .clear + overlay.layer.zPosition = Metric.overlayZPosition + overlay.addGestureRecognizer( + UITapGestureRecognizer( + target: self, + action: #selector(handleOutsideTap) + ) + ) + return overlay + } + + @objc private func handleOutsideTap() { + guard let presentedDropdown else { + return + } + dismiss(presentedDropdown) + } +} + +extension KoinDropdownHost { + + private func addBottomInset( + travel: CGFloat, + of dropdown: KoinDropdown, + in scrollView: UIScrollView + ) -> CGFloat { + guard let trigger = dropdown.trigger else { + return 0 + } + + let triggerMaxY = trigger.convert(trigger.bounds, to: scrollView).maxY + let dropdownMaxY = triggerMaxY + travel + let viewport = scrollView.bounds.height + let maxOffsetY = max( + -scrollView.adjustedContentInset.top, + scrollView.contentSize.height + scrollView.adjustedContentInset.bottom - viewport + ) + let reachableBottomY = maxOffsetY + viewport + let overflow = dropdownMaxY - reachableBottomY + + guard 0 < overflow else { + return 0 + } + + scrollView.contentInset.bottom += overflow + return overflow + } + + private func finishDismiss(of dropdown: KoinDropdown) { + guard presentedDropdown === dropdown else { return } + + presentedDropdown = nil + overlay = nil + scrollView?.isScrollEnabled = true + } +} diff --git a/Koin/Core/View/KoinModalViewController/Configuration/KoinModalConfiguration.swift b/Koin/Core/View/KoinModalViewController/Configuration/KoinModalConfiguration.swift new file mode 100644 index 00000000..2ed1fcd3 --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/Configuration/KoinModalConfiguration.swift @@ -0,0 +1,208 @@ +// +// KoinModalConfiguration.swift +// koin +// +// Created by 홍기정 on 8/15/26. +// + +import UIKit + +struct KoinModalConfiguration { + let appearance: Appearance + let content: Content + let button: Button + let layout: Layout + + init( + appearance: Appearance, + content: Content, + button: Button, + layout: Layout = .init() + ) { + self.appearance = appearance + self.content = content + self.button = button + self.layout = layout + } + + enum Appearance { + case primary + case new + case destructive + } + + enum Content { + case titles( + mainTitleText: String, + mainTitleStyle: KoinModalStyle.TitleStyle? = nil, + subTitleText: String, + subTitleStyle : KoinModalStyle.TitleStyle? = nil + ) + case singleTitle( + text: String, + style: KoinModalStyle.TitleStyle? = nil + ) + case attributedTitles( + mainTitle: NSAttributedString, + subTitle: NSAttributedString + ) + case attributedSingleTitle( + title: NSAttributedString + ) + case custom( + customView: UIView + ) + } + + enum Button { + case buttons( + leftButtonTitle: String, + leftButtonAction: (()->Void)? = nil, + leftButtonStyle: KoinModalStyle.ButtonStyle? = nil, + rightButtonTitle: String, + rightButtonAction: ()->Void, + rightButtonStyle: KoinModalStyle.ButtonStyle? = nil + ) + case singleButton( + title: String, + action: (()->Void)? = nil, + style: KoinModalStyle.ButtonStyle? = nil + ) + case none + } + + struct Layout { + let width: CGFloat + let contentTopPadding: CGFloat + let contentHorizontalPadding: CGFloat + let contentBottomPadding: CGFloat + let paddingBetweenContentAndButton: CGFloat + let buttonHorizontalPadding: CGFloat + let buttonBottomPadding: CGFloat + + init( + width: CGFloat = 301, + contentTopPadding: CGFloat = 24, + contentHorizontalPadding: CGFloat = 32, + contentBottomPadding: CGFloat = 0, + paddingBetweenContentAndButton: CGFloat = 24, + buttonHorizontalPadding: CGFloat = 32, + buttonBottomPadding: CGFloat = 24 + ) { + self.width = width + self.contentTopPadding = contentTopPadding + self.contentHorizontalPadding = contentHorizontalPadding + self.contentBottomPadding = contentBottomPadding + self.paddingBetweenContentAndButton = paddingBetweenContentAndButton + self.buttonHorizontalPadding = buttonHorizontalPadding + self.buttonBottomPadding = buttonBottomPadding + } + } +} + +extension KoinModalConfiguration { + var style: KoinModalStyle { + switch appearance { + case .primary: + KoinModalStyle( + mainTitle: .init( + textColor: .neutral700, + font: .pretendardMedium, + fontSize: 18 + ), + subTitle: .init( + textColor: .neutral500, + font: .pretendardRegular, + fontSize: 14 + ), + singleTitle: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 14 + ), + leftButton: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 15, + borderColor: .neutral500, + borderWidth: 1, + cornerRadius: 8 + ), + rightButton: .init( + textColor: .neutral0, + font: .pretendardMedium, + fontSize: 15, + backgroundColor: .primary500, + cornerRadius: 8 + ) + ) + case .new: + KoinModalStyle( + mainTitle: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 18), + subTitle: .init( + textColor: .neutral500, + font: .pretendardRegular, + fontSize: 14 + ), + singleTitle: .init( + textColor: .neutral600, + font: .pretendardRegular, + fontSize: 15 + ), + leftButton: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 15, + borderColor: .neutral400, + borderWidth: 1, + cornerRadius: 6 + ), + rightButton: .init( + textColor: .neutral0, + font: .pretendardMedium, + fontSize: 15, + backgroundColor: .new500, + cornerRadius: 6 + ) + ) + case .destructive: + KoinModalStyle( + mainTitle: .init( + textColor: .neutral700, + font: .pretendardMedium, + fontSize: 18 + ), + subTitle: .init( + textColor: .neutral500, + font: .pretendardRegular, + fontSize: 14 + ), + singleTitle: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 14 + ), + leftButton: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 15, + borderColor: .neutral500, + borderWidth: 1, + cornerRadius: 4 + ), + rightButton: .init( + textColor: .neutral0, + font: .pretendardMedium, + fontSize: 15, + backgroundColor: .danger700, + cornerRadius: 4 + ) + ) + } + } +} + + diff --git a/Koin/Core/View/KoinModalViewController/Configuration/KoinModalStyle.swift b/Koin/Core/View/KoinModalViewController/Configuration/KoinModalStyle.swift new file mode 100644 index 00000000..2624f782 --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/Configuration/KoinModalStyle.swift @@ -0,0 +1,83 @@ +// +// KoinModalStyle.swift +// koin +// +// Created by 홍기정 on 8/15/26. +// + +import UIKit + +struct KoinModalStyle { + let mainTitle: TitleStyle + let subTitle: TitleStyle + let singleTitle: TitleStyle + let leftButton: ButtonStyle + let rightButton: ButtonStyle + + var singleButton: ButtonStyle { + rightButton + } + + struct TitleStyle { + let textColor: ColorAsset + let font: FontAsset + let fontSize: Int + } + + struct ButtonStyle { + let textColor: ColorAsset + let font: FontAsset + let fontSize: Int + let backgroundColor: ColorAsset? + let borderColor: ColorAsset? + let borderWidth: CGFloat? + let cornerRadius: CGFloat? + + init( + textColor: ColorAsset, + font: FontAsset, + fontSize: Int + ) { + self.textColor = textColor + self.font = font + self.fontSize = fontSize + self.backgroundColor = nil + self.borderColor = nil + self.borderWidth = nil + self.cornerRadius = nil + } + + init( + textColor: ColorAsset, + font: FontAsset, + fontSize: Int, + backgroundColor: ColorAsset, + cornerRadius: CGFloat + ) { + self.textColor = textColor + self.font = font + self.fontSize = fontSize + self.backgroundColor = backgroundColor + self.borderColor = nil + self.borderWidth = nil + self.cornerRadius = cornerRadius + } + + init( + textColor: ColorAsset, + font: FontAsset, + fontSize: Int, + borderColor: ColorAsset, + borderWidth: CGFloat, + cornerRadius: CGFloat + ) { + self.textColor = textColor + self.font = font + self.fontSize = fontSize + self.backgroundColor = nil + self.borderColor = borderColor + self.borderWidth = borderWidth + self.cornerRadius = cornerRadius + } + } +} diff --git a/Koin/Core/View/KoinModalViewController/KoinModalViewController.swift b/Koin/Core/View/KoinModalViewController/KoinModalViewController.swift new file mode 100644 index 00000000..56074646 --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/KoinModalViewController.swift @@ -0,0 +1,216 @@ +// +// KoinModalViewController.swift +// koin +// +// Created by 홍기정 on 8/15/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +class KoinModalViewController: UIViewController { + + // MARK: - Properties + private let configuration: KoinModalConfiguration + private var subscriptions = Set() + + private let leftButtonAction: (()->Void)? + private let rightButtonAction: (()->Void)? + private let singleButtonAction: (()->Void)? + + // MARK: - UI Components + private let containerLayoutGuide = UILayoutGuide() + private let containerView = UIView() + private var contentView: ModalContentView + private let buttonView: ModalButtonView + + // MARK: - Initializer + init(configuration: KoinModalConfiguration) { + self.configuration = configuration + self.contentView = ModalContentView(configuration: configuration) + self.buttonView = ModalButtonView(configuration: configuration) + + switch configuration.button { + case .buttons(_, let leftButtonAction, _, _, let rightButtonAction, _): + self.leftButtonAction = leftButtonAction + self.rightButtonAction = rightButtonAction + self.singleButtonAction = nil + case .singleButton(_, let action, _): + self.leftButtonAction = nil + self.rightButtonAction = nil + self.singleButtonAction = action + default: + self.leftButtonAction = nil + self.rightButtonAction = nil + self.singleButtonAction = nil + } + super.init(nibName: nil, bundle: nil) + configureTransition() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + setUpGestureRecognizer() + configureView() + bind() + } + + // MARK: - Bind + private func bind() { + buttonView.leftButtonTappedPublisher.sink { [weak self] in + self?.leftButtonTapped() + }.store(in: &subscriptions) + + buttonView.rightButtonTappedPublisher.sink { [weak self] in + self?.rightButtonTapped() + }.store(in: &subscriptions) + + buttonView.singleButtonTappedPublisher.sink { [weak self] in + self?.singleButtonTapped() + }.store(in: &subscriptions) + } + + func leftButtonTapped() { + dismiss(animated: true) { [weak self] in + self?.leftButtonAction?() + } + } + + func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + self?.rightButtonAction?() + } + } + + func singleButtonTapped() { + dismiss(animated: true) { [weak self] in + self?.singleButtonAction?() + } + } +} + +extension KoinModalViewController: UIViewControllerTransitioningDelegate { + private func configureTransition() { + modalPresentationStyle = .custom + transitioningDelegate = self + } + + func animationController( + forPresented presented: UIViewController, + presenting: UIViewController, + source: UIViewController + ) -> (any UIViewControllerAnimatedTransitioning)? { + KoinModalAnimator(transitionType: .present) + } + + func animationController( + forDismissed dismissed: UIViewController + ) -> (any UIViewControllerAnimatedTransitioning)? { + KoinModalAnimator(transitionType: .dismiss) + } + + func presentationController( + forPresented presented: UIViewController, + presenting: UIViewController?, + source: UIViewController + ) -> UIPresentationController? { + KoinModalPresentationController( + presentedViewController: presented, + presenting: presenting + ) + } +} + +extension KoinModalViewController { + private func setUpGestureRecognizer() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapAround)) + tapGesture.cancelsTouchesInView = false + view.addGestureRecognizer(tapGesture) + } + + @objc private func didTapAround(_ sender: UITapGestureRecognizer) { + let location = sender.location(in: view) + if !containerView.frame.contains(location) { + dismiss(animated: true) + } else { + view.endEditing(true) + } + } +} + +extension KoinModalViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + view.backgroundColor = .clear + + containerView.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.cornerRadius = 8 + } + } + + private func setUpLayouts() { + [contentView, buttonView].forEach { + containerView.addSubview($0) + } + [containerView].forEach { + view.addSubview($0) + } + + view.addLayoutGuide(containerLayoutGuide) + + + switch configuration.button { + case .none: + buttonView.removeFromSuperview() + case .buttons, .singleButton: + break + } + } + private func setUpConstraints() { + switch configuration.button { + case .buttons, .singleButton: + contentView.snp.makeConstraints { + $0.top.equalToSuperview().offset(configuration.layout.contentTopPadding) + $0.leading.equalToSuperview().offset(configuration.layout.contentHorizontalPadding) + $0.trailing.equalToSuperview().offset(-configuration.layout.contentHorizontalPadding) + } + buttonView.snp.makeConstraints { + $0.top.equalTo(contentView.snp.bottom).offset(configuration.layout.paddingBetweenContentAndButton) + $0.leading.equalToSuperview().offset(configuration.layout.buttonHorizontalPadding) + $0.trailing.equalToSuperview().offset(-configuration.layout.buttonHorizontalPadding) + $0.bottom.equalToSuperview().offset(-configuration.layout.buttonBottomPadding) + } + case .none: + contentView.snp.makeConstraints { + $0.top.equalToSuperview().offset(configuration.layout.contentTopPadding) + $0.leading.equalToSuperview().offset(configuration.layout.contentHorizontalPadding) + $0.trailing.equalToSuperview().offset(-configuration.layout.contentHorizontalPadding) + $0.bottom.equalToSuperview().offset(-configuration.layout.contentBottomPadding) + } + } + + containerView.snp.makeConstraints { + $0.center.equalTo(containerLayoutGuide) + $0.width.equalTo(configuration.layout.width) + } + + containerLayoutGuide.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) + $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top) + $0.leading.trailing.equalToSuperview() + } + } +} diff --git a/Koin/Core/View/KoinModalViewController/Subviews/ModalButtonView.swift b/Koin/Core/View/KoinModalViewController/Subviews/ModalButtonView.swift new file mode 100644 index 00000000..cebb2af8 --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/Subviews/ModalButtonView.swift @@ -0,0 +1,159 @@ +// +// ModalButtonView.swift +// koin +// +// Created by 홍기정 on 8/15/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class ModalButtonView: UIView { + + private enum Layout { + static let PaddingBetweenButtons: CGFloat = 8 + static let ButtonHeight: CGFloat = 48 + } + + // MARK: - Properties + private let configuration: KoinModalConfiguration + let leftButtonTappedPublisher = PassthroughSubject() + let rightButtonTappedPublisher = PassthroughSubject() + let singleButtonTappedPublisher = PassthroughSubject() + + // MARK: - UI Components + private let leftButton = UIButton() + private let rightButton = UIButton() + private let singleButton = UIButton() + + // MARK: - Initializer + init(configuration: KoinModalConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + + configureView() + setUpAddTargets() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ModalButtonView { + private func setUpAddTargets() { + leftButton.addTarget(self, action: #selector(onLeftButtonTapped), for: .touchUpInside) + rightButton.addTarget(self, action: #selector(onRightButtonTapped), for: .touchUpInside) + singleButton.addTarget(self, action: #selector(onSingleButtonTapped), for: .touchUpInside) + } + + // MARK: - Objc + @objc private func onLeftButtonTapped() { + leftButtonTappedPublisher.send() + } + @objc private func onRightButtonTapped() { + rightButtonTappedPublisher.send() + } + @objc private func onSingleButtonTapped() { + singleButtonTappedPublisher.send() + } +} + +extension ModalButtonView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + switch configuration.button { + case .buttons(let leftButtonTitle, _, let leftButtonStyle, let rightButtonTitle, _, let rightButtonStyle): + let leftButtonTitle = leftButtonTitle + let leftButtonStyle = leftButtonStyle ?? configuration.style.leftButton + let rightButtonTitle = rightButtonTitle + let rightButtonStyle = rightButtonStyle ?? configuration.style.rightButton + + apply(title: leftButtonTitle, style: leftButtonStyle, to: leftButton) + apply(title: rightButtonTitle, style: rightButtonStyle, to: rightButton) + case .singleButton(let title, _, let style): + let title = title + let style = style ?? configuration.style.singleButton + + apply(title: title, style: style, to: singleButton) + case .none: + return + } + } + + private func setUpLayouts() { + switch configuration.button { + case .buttons: + [leftButton, rightButton].forEach { + addSubview($0) + } + case .singleButton: + [singleButton].forEach { + addSubview($0) + } + case .none: + return + } + } + + private func setUpConstraints() { + switch configuration.button { + case .buttons: + leftButton.snp.makeConstraints { + $0.height.equalTo(Layout.ButtonHeight) + $0.top.leading.bottom.equalToSuperview() + } + rightButton.snp.makeConstraints { + $0.height.equalTo(Layout.ButtonHeight) + $0.width.equalTo(leftButton.snp.width) + $0.leading.equalTo(leftButton.snp.trailing).offset(Layout.PaddingBetweenButtons) + $0.top.trailing.bottom.equalToSuperview() + } + case .singleButton: + singleButton.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(Layout.ButtonHeight) + } + case .none: + return + } + } +} + +extension ModalButtonView { + private func apply(title: String, style: KoinModalStyle.ButtonStyle, to button: UIButton) { + button.do { + var configuration = UIButton.Configuration.plain() + + configuration.attributedTitle = AttributedString( + title, + attributes: AttributeContainer([ + .font: UIFont.appFont(style.font, size: style.fontSize), + .foregroundColor: UIColor.appColor(style.textColor) + ]) + ) + $0.configuration = configuration + + if let backgroundColor = style.backgroundColor { + $0.backgroundColor = .appColor(backgroundColor) + } + if let borderColor = style.borderColor { + $0.layer.borderColor = UIColor.appColor(borderColor).cgColor + } + if let borderWidth = style.borderWidth { + $0.layer.borderWidth = borderWidth + } + if let cornerRadius = style.cornerRadius { + $0.layer.cornerRadius = cornerRadius + } + + $0.clipsToBounds = true + } + } +} diff --git a/Koin/Core/View/KoinModalViewController/Subviews/ModalContentView.swift b/Koin/Core/View/KoinModalViewController/Subviews/ModalContentView.swift new file mode 100644 index 00000000..d22dc07b --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/Subviews/ModalContentView.swift @@ -0,0 +1,146 @@ +// +// ModalContentView.swift +// koin +// +// Created by 홍기정 on 8/15/26. +// + +import UIKit +import SnapKit +import Then + +final class ModalContentView: UIView { + private static let paddingBetweenTitles: CGFloat = 8 + + // MARK: - Porperties + private let configuration: KoinModalConfiguration + + // MARK: - UI Components + private let mainTitleLabel = UILabel() + private let subTitleLabel = UILabel() + + private let singleTitleLabel = UILabel() + + private var customView: UIView? + + // MARK: - Initializer + init(configuration: KoinModalConfiguration) { + self.configuration = configuration + super.init(frame: .zero) + configureView() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension ModalContentView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + switch configuration.content { + case .titles(let mainTitleText, let mainTitleStyle, let subTitleText, let subTitleStyle): + let mainTitleStyle = mainTitleStyle ?? configuration.style.mainTitle + let subTitleStyle = subTitleStyle ?? configuration.style.subTitle + apply(text: mainTitleText, style: mainTitleStyle, to: mainTitleLabel) + apply(text: subTitleText, style: subTitleStyle, to: subTitleLabel) + case .singleTitle(let text, let style): + let style = style ?? configuration.style.singleTitle + apply(text: text, style: style, to: singleTitleLabel) + case .attributedTitles(let mainTitle, let subTitle): + apply(title: mainTitle, to: mainTitleLabel) + apply(title: subTitle, to: subTitleLabel) + case .attributedSingleTitle(let title): + apply(title: title, to: singleTitleLabel) + case .custom(let customView): + self.customView = customView + } + } + + private func setUpLayouts() { + switch configuration.content { + case .titles, .attributedTitles: + [mainTitleLabel, subTitleLabel].forEach { + addSubview($0) + } + case .singleTitle, .attributedSingleTitle: + [singleTitleLabel].forEach { + addSubview($0) + } + case .custom(let customView): + [customView].forEach { + addSubview($0) + } + } + } + + private func setUpConstraints() { + switch configuration.content { + case .titles, .attributedTitles: + let mainTitleLabelTopOffset: CGFloat = mainTitleLabel.font.pointSize * 0.4 + mainTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(-mainTitleLabelTopOffset) + $0.leading.greaterThanOrEqualToSuperview() + $0.trailing.lessThanOrEqualToSuperview() + $0.centerX.equalToSuperview() + } + subTitleLabel.snp.makeConstraints { + $0.top.equalTo(mainTitleLabel.snp.bottom).offset(Self.paddingBetweenTitles) + $0.leading.greaterThanOrEqualToSuperview() + $0.trailing.lessThanOrEqualToSuperview() + $0.bottom.equalToSuperview() + $0.centerX.equalToSuperview() + } + case .singleTitle, .attributedSingleTitle: + let singleTitleLabelTopOffset: CGFloat = singleTitleLabel.font.pointSize * 0.4 + singleTitleLabel.snp.makeConstraints { + $0.top.bottom.equalToSuperview().offset(-singleTitleLabelTopOffset) + $0.leading.greaterThanOrEqualToSuperview() + $0.trailing.lessThanOrEqualToSuperview() + $0.centerX.equalToSuperview() + } + case .custom: + customView?.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + } +} + +extension ModalContentView { + private func apply( + text: String, + style: KoinModalStyle.TitleStyle, + to label: UILabel + ) { + label.do { + $0.font = .appFont(style.font, size: style.fontSize) + $0.textColor = .appColor(style.textColor) + $0.setLineHeight(lineHeight: 1.6, text: text) + $0.textAlignment = .center + $0.numberOfLines = 0 + } + } + + private func apply( + title attributedString: NSAttributedString, + to label: UILabel + ) { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + let paragraphStyle = NSMutableParagraphStyle().then { + $0.lineHeightMultiple = 1.6 + $0.alignment = .center + } + let fullRange = { + let text = attributedString.string + return (text as NSString).range(of: text) + }() + mutableAttributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: fullRange) + label.attributedText = mutableAttributedString + label.numberOfLines = 0 + } +} diff --git a/Koin/Core/View/KoinModalViewController/Transition/KoinModalAnimator.swift b/Koin/Core/View/KoinModalViewController/Transition/KoinModalAnimator.swift new file mode 100644 index 00000000..ca8a2056 --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/Transition/KoinModalAnimator.swift @@ -0,0 +1,95 @@ +// +// KoinModalAnimator.swift +// koin +// +// Created by 홍기정 on 8/14/26. +// + +import UIKit + +final class KoinModalAnimator: NSObject, UIViewControllerAnimatedTransitioning { + + enum TransitionType { + case present + case dismiss + } + + // MARK: - Properties + private let duration: TimeInterval = 0.2 + private let deflatedTransition = CGAffineTransform(scaleX: 0.9, y: 0.9) + private let transitionType: TransitionType + + // MARK: - Initializer + init(transitionType: TransitionType) { + self.transitionType = transitionType + } + + // MARK: - Transition + func transitionDuration( + using transitionContext: (any UIViewControllerContextTransitioning)? + ) -> TimeInterval { + duration + } + + func animateTransition( + using transitionContext: any UIViewControllerContextTransitioning + ) { + switch transitionType { + case .present: + animatePresentation(using: transitionContext) + case .dismiss: + animateDismissal(using: transitionContext) + } + } + + private func animatePresentation( + using transitionContext: any UIViewControllerContextTransitioning + ) { + guard + let presentedViewController = transitionContext.viewController(forKey: .to), + let presentedView = transitionContext.view(forKey: .to) + else { + transitionContext.completeTransition(false) + return + } + + let containerView = transitionContext.containerView + presentedView.frame = transitionContext.finalFrame(for: presentedViewController) + containerView.addSubview(presentedView) + + presentedView.alpha = 0 + presentedView.transform = deflatedTransition + + UIView.animate(springDuration: duration) { + presentedView.alpha = 1 + presentedView.transform = .identity + } completion: { _ in + let didComplete = !transitionContext.transitionWasCancelled + if !didComplete { + presentedView.removeFromSuperview() + } + transitionContext.completeTransition(didComplete) + } + } + + private func animateDismissal( + using transitionContext: any UIViewControllerContextTransitioning + ) { + guard let presentedView = transitionContext.view(forKey: .from) else { + transitionContext.completeTransition(false) + return + } + + UIView.animate(springDuration: duration) { + presentedView.alpha = 0 + presentedView.transform = self.deflatedTransition + } completion: { _ in + let didComplete = !transitionContext.transitionWasCancelled + if !didComplete { + presentedView.alpha = 1 + presentedView.transform = .identity + } + transitionContext.completeTransition(didComplete) + } + } +} diff --git a/Koin/Core/View/KoinModalViewController/Transition/KoinModalPresentationController.swift b/Koin/Core/View/KoinModalViewController/Transition/KoinModalPresentationController.swift new file mode 100644 index 00000000..99a6e1d0 --- /dev/null +++ b/Koin/Core/View/KoinModalViewController/Transition/KoinModalPresentationController.swift @@ -0,0 +1,80 @@ +// +// KoinModalPresentationController.swift +// koin +// +// Created by 홍기정 on 8/14/26. +// + +import UIKit +import Then + +final class KoinModalPresentationController: UIPresentationController { + + // MARK: - UI Components + private let dimmingView = UIView().then { + $0.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) + $0.alpha = 0 + } + + // MARK: - Layout + override var frameOfPresentedViewInContainerView: CGRect { + containerView?.bounds ?? .zero + } + + override func containerViewWillLayoutSubviews() { + super.containerViewWillLayoutSubviews() + dimmingView.frame = containerView?.bounds ?? .zero + presentedView?.frame = frameOfPresentedViewInContainerView + } + + // MARK: - Present + override func presentationTransitionWillBegin() { + super.presentationTransitionWillBegin() + + guard let containerView else { return } + + dimmingView.frame = containerView.bounds + containerView.addSubview(dimmingView) + + guard let transitionCoordinator = presentedViewController.transitionCoordinator else { + dimmingView.alpha = 1 + return + } + + transitionCoordinator.animate { [weak self] _ in + self?.dimmingView.alpha = 1 + } + } + + override func presentationTransitionDidEnd(_ completed: Bool) { + super.presentationTransitionDidEnd(completed) + + if !completed { + dimmingView.removeFromSuperview() + } + } + + // MARK: - Dismiss + override func dismissalTransitionWillBegin() { + super.dismissalTransitionWillBegin() + + guard let transitionCoordinator = presentedViewController.transitionCoordinator else { + dimmingView.alpha = 0 + return + } + + transitionCoordinator.animate { [weak self] _ in + self?.dimmingView.alpha = 0 + } + } + + override func dismissalTransitionDidEnd(_ completed: Bool) { + super.dismissalTransitionDidEnd(completed) + + if completed { + dimmingView.removeFromSuperview() + } else { + dimmingView.alpha = 1 + } + } +} diff --git a/Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift b/Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift index 0e7907a5..8efa0534 100644 --- a/Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift +++ b/Koin/Core/View/KoinPickerDropDownView/KoinPickerDropDownView.swift @@ -63,6 +63,16 @@ final class KoinPickerDropDownView: UIView { } } +// MARK: - KoinDropdownContentView +extension KoinPickerDropDownView: KoinDropdownContentView { + var dismissTappedPublisher: AnyPublisher { + applyButtonTappedPublisher.eraseToAnyPublisher() + } + var height: CGFloat { + return 153 + } +} + extension KoinPickerDropDownView { private func bind() { @@ -134,6 +144,7 @@ extension KoinPickerDropDownView { private func setUpConstraints() { pickerView.snp.makeConstraints { + $0.height.equalTo(90) $0.top.equalToSuperview().offset(12) $0.centerX.equalToSuperview() } diff --git a/Koin/Core/View/KoinPickerView.swift b/Koin/Core/View/KoinPickerView.swift index 6db1e108..2992959b 100644 --- a/Koin/Core/View/KoinPickerView.swift +++ b/Koin/Core/View/KoinPickerView.swift @@ -24,7 +24,11 @@ final class KoinPickerView: UIView, UIPickerViewDelegate, UIPickerViewDataSource private let pickerView = UIPickerView(frame: .zero) // MARK: - Initialization - init(font: UIFont = .appFont(.pretendardMedium, size: 16), selectedColor: UIColor = .appColor(.primary500), deselectedColor: UIColor = .appColor(.neutral800)) { + init( + font: UIFont = .appFont(.pretendardMedium, size: 16), + selectedColor: UIColor = .appColor(.primary500), + deselectedColor: UIColor = .appColor(.neutral800) + ) { self.font = font self.selectedColor = selectedColor self.deselectedColor = deselectedColor diff --git a/Koin/Core/View/ModalViewController.swift b/Koin/Core/View/ModalViewController.swift deleted file mode 100644 index 5813df5f..00000000 --- a/Koin/Core/View/ModalViewController.swift +++ /dev/null @@ -1,213 +0,0 @@ -// -// LoginModalViewController.swift -// koin -// -// Created by JOOMINKYUNG on 8/28/24. -// - -import Combine -import UIKit - -class ModalViewController: UIViewController { - let rightButtonPublisher = PassthroughSubject() - let leftButtonPublisher = PassthroughSubject() - var containerWidth: CGFloat = 0 - var containerHeight: CGFloat = 0 - var paddingBetweenLabels: CGFloat = 0 - var titleText: String = "" - var subTitleText: String = "" - var titleColor: UIColor = .black - var subTitleColor: UIColor = .black - - private let messageLabel = UILabel().then { - $0.numberOfLines = 0 - } - - private let subMessageLabel = UILabel().then { - $0.numberOfLines = 0 - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("닫기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let rightButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 8 - view.layer.masksToBounds = true - return view - }() - - private var contentViewInContainer: UIView? - - init(width: CGFloat, height: CGFloat, paddingBetweenLabels: CGFloat, title: String, subTitle: String, titleColor: UIColor, subTitleColor: UIColor, rightButtonText: String = "로그인하기") { - super.init(nibName: nil, bundle: nil) - self.containerWidth = width - self.containerHeight = height - self.paddingBetweenLabels = paddingBetweenLabels - self.titleText = title - self.subTitleText = subTitle - self.titleColor = titleColor - self.subTitleColor = subTitleColor - self.rightButton.setTitle(rightButtonText, for: .normal) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - rightButton.addTarget(self, action: #selector(rightButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapOutsideOfContainerView)) - view.addGestureRecognizer(tapGesture) - } - - @objc func closeButtonTapped() { - leftButtonPublisher.send() - dismiss(animated: true, completion: nil) - } - - @objc func rightButtonTapped() { - rightButtonPublisher.send() - dismiss(animated: true, completion: nil) - } - - @objc func tapOutsideOfContainerView(_ sender: UITapGestureRecognizer) { - let location = sender.location(in: view) - if !containerView.frame.contains(location) { - dismiss(animated: true, completion: nil) - } - } - - func updaterightButton(buttonColor: UIColor = .appColor(.primary500), borderWidth: CGFloat, title: String) { - rightButton.backgroundColor = buttonColor - rightButton.layer.borderWidth = borderWidth - rightButton.setTitle(title, for: .normal) - } - - func updateCloseButton(buttonColor: UIColor = .systemBackground, borderWidth: CGFloat, title: String) { - closeButton.backgroundColor = buttonColor - closeButton.layer.borderWidth = borderWidth - closeButton.setTitle(title, for: .normal) - } - - func updateMessageLabel(font: UIFont = .appFont(.pretendardMedium, size: 18), alignment: NSTextAlignment = .center, title: String? = nil) { - if let title = title { titleText = title } - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 8 - paragraphStyle.alignment = alignment - let attributedString = NSMutableAttributedString(string: titleText) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: titleText.count)) - attributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: titleText.count)) - attributedString.addAttribute(.foregroundColor, value: titleColor, range: NSRange(location: 0, length: titleText.count)) - - messageLabel.attributedText = attributedString - } - - func updateSubMessageLabel(font: UIFont = .appFont(.pretendardRegular, size: 14), alignment: NSTextAlignment = .center, title: String? = nil) { - if let title = title { subTitleText = title } - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - paragraphStyle.alignment = alignment - let attributedString = NSMutableAttributedString(string: subTitleText) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: subTitleText.count)) - attributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: subTitleText.count)) - attributedString.addAttribute(.foregroundColor, value: subTitleColor, range: NSRange(location: 0, length: subTitleText.count)) - - subMessageLabel.attributedText = attributedString - } - - func setContentViewInContainer(view: UIView, frame: CGRect) { - self.contentViewInContainer = view - self.contentViewInContainer?.frame = frame - - guard let contentViewInContainer = contentViewInContainer else { return } - containerView.addSubview(contentViewInContainer) - contentViewInContainer.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.trailing.equalToSuperview() - make.height.equalTo(contentViewInContainer.frame.height) - } - closeButton.snp.remakeConstraints { make in - make.top.equalTo(contentViewInContainer.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - rightButton.snp.remakeConstraints { make in - make.top.equalTo(contentViewInContainer.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } -} - -extension ModalViewController { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, subMessageLabel, closeButton, rightButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(containerWidth) - make.height.equalTo(containerHeight) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(24) - make.leading.trailing.equalToSuperview().inset(24) - } - subMessageLabel.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(paddingBetweenLabels) - make.leading.trailing.equalToSuperview().inset(24) - } - closeButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - rightButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - updateMessageLabel() - updateSubMessageLabel() - } -} diff --git a/Koin/Core/View/ModalViewControllerB.swift b/Koin/Core/View/ModalViewControllerB.swift deleted file mode 100644 index be3142f2..00000000 --- a/Koin/Core/View/ModalViewControllerB.swift +++ /dev/null @@ -1,230 +0,0 @@ -// -// ModalViewControllerB.swift -// koin -// -// Created by 홍기정 on 1/19/26. -// - -import Combine -import UIKit - -class ModalViewControllerB: UIViewController { - - // MARK: - Properties - private let onLeftButtonTapped: (()->Void)? - private let onRightButtonTapped: ()->Void - - var containerWidth: CGFloat = 0 - var containerHeight: CGFloat = 0 - var paddingBetweenLabels: CGFloat = 0 - var titleText: String = "" - var subTitleText: String? - var titleColor: UIColor = .black - var subTitleColor: UIColor? = .black - - // MARK: - UI Components - private let messageLabel = UILabel().then { - $0.numberOfLines = 0 - } - - private let subMessageLabel = UILabel().then { - $0.numberOfLines = 0 - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("닫기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let rightButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 8 - view.layer.masksToBounds = true - return view - }() - - private var contentViewInContainer: UIView? - - init(onLeftButtonTapped: (()->Void)? = nil, onRightButtonTapped: @escaping ()->Void, width: CGFloat, height: CGFloat, paddingBetweenLabels: CGFloat, title: String, subTitle: String?, titleColor: UIColor, subTitleColor: UIColor?, rightButtonText: String = "로그인하기") { - self.onLeftButtonTapped = onLeftButtonTapped - self.onRightButtonTapped = onRightButtonTapped - super.init(nibName: nil, bundle: nil) - self.containerWidth = width - self.containerHeight = height - self.paddingBetweenLabels = paddingBetweenLabels - self.titleText = title - self.subTitleText = subTitle - self.titleColor = titleColor - self.subTitleColor = subTitleColor - self.rightButton.setTitle(rightButtonText, for: .normal) - } - - convenience init(onLeftButtonTapped: (()->Void)? = nil, onRightButtonTapped: @escaping ()->Void, width: CGFloat, height: CGFloat, title: String, titleColor: UIColor, rightButtonText: String = "로그인하기") { - - self.init(onLeftButtonTapped: onLeftButtonTapped, onRightButtonTapped: onRightButtonTapped, width: width, height: height, paddingBetweenLabels: 0, title: title, subTitle: nil, titleColor: titleColor, subTitleColor: nil, rightButtonText: rightButtonText) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - rightButton.addTarget(self, action: #selector(rightButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(tapOutsideOfContainerView)) - view.addGestureRecognizer(tapGesture) - } - - @objc func closeButtonTapped() { - onLeftButtonTapped?() - dismiss(animated: true, completion: nil) - } - - @objc func rightButtonTapped() { - onRightButtonTapped() - dismiss(animated: true, completion: nil) - } - - @objc func tapOutsideOfContainerView(_ sender: UITapGestureRecognizer) { - let location = sender.location(in: view) - if !containerView.frame.contains(location) { - dismiss(animated: true, completion: nil) - } - } - - func updateRightButton(buttonColor: UIColor = .appColor(.primary500), borderWidth: CGFloat, title: String) { - rightButton.backgroundColor = buttonColor - rightButton.layer.borderWidth = borderWidth - rightButton.setTitle(title, for: .normal) - } - - func updateCloseButton(buttonColor: UIColor = .systemBackground, borderWidth: CGFloat, title: String) { - closeButton.backgroundColor = buttonColor - closeButton.layer.borderWidth = borderWidth - closeButton.setTitle(title, for: .normal) - } - - func updateMessageLabel(font: UIFont = .appFont(.pretendardMedium, size: 18), alignment: NSTextAlignment = .center, title: String? = nil) { - if let title = title { titleText = title } - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 8 - paragraphStyle.alignment = alignment - let attributedString = NSMutableAttributedString(string: titleText) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: titleText.count)) - attributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: titleText.count)) - attributedString.addAttribute(.foregroundColor, value: titleColor, range: NSRange(location: 0, length: titleText.count)) - - messageLabel.attributedText = attributedString - } - - func updateSubMessageLabel(font: UIFont = .appFont(.pretendardRegular, size: 14), alignment: NSTextAlignment = .center, title: String? = nil) { - if let title = title { subTitleText = title } - - if let subTitleText { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - paragraphStyle.alignment = alignment - let attributedString = NSMutableAttributedString(string: subTitleText) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: subTitleText.count)) - attributedString.addAttribute(.font, value: font, range: NSRange(location: 0, length: subTitleText.count)) - attributedString.addAttribute(.foregroundColor, value: subTitleColor, range: NSRange(location: 0, length: subTitleText.count)) - - subMessageLabel.attributedText = attributedString - } - else { - updateMessageLabel(font: .appFont(.pretendardMedium, size: 16)) - } - } - - func setContentViewInContainer(view: UIView, frame: CGRect) { - self.contentViewInContainer = view - self.contentViewInContainer?.frame = frame - - guard let contentViewInContainer = contentViewInContainer else { return } - containerView.addSubview(contentViewInContainer) - contentViewInContainer.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.trailing.equalToSuperview() - make.height.equalTo(contentViewInContainer.frame.height) - } - closeButton.snp.remakeConstraints { make in - make.top.equalTo(contentViewInContainer.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - rightButton.snp.remakeConstraints { make in - make.top.equalTo(contentViewInContainer.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } -} - -extension ModalViewControllerB { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, subMessageLabel, closeButton, rightButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(containerWidth) - make.height.equalTo(containerHeight) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(24) - make.leading.trailing.equalToSuperview().inset(24) - } - subMessageLabel.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(paddingBetweenLabels) - make.leading.trailing.equalToSuperview().inset(24) - } - closeButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - rightButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - updateMessageLabel() - updateSubMessageLabel() - } -} diff --git a/Koin/Core/View/OrderHistoryUIComponents/EmptyStateView.swift b/Koin/Core/View/OrderHistoryUIComponents/EmptyStateView.swift deleted file mode 100644 index 94db407b..00000000 --- a/Koin/Core/View/OrderHistoryUIComponents/EmptyStateView.swift +++ /dev/null @@ -1,133 +0,0 @@ -// -// EmptyStateView.swift -// koin -// -// Created by 김성민 on 9/19/25. -// - -import UIKit -import SnapKit - -final class EmptyStateView: UIView { - - var onTapAction: (() -> Void)? - private var symbolCenterY: Constraint? - - struct Config{ - let title: String - let showSeeOrderHistoryButton: Bool - } - - private let centerGuide = UILayoutGuide() - - private let symbolImageView = UIImageView().then { - $0.image = UIImage.appImage(asset: .sleepBcsdSymbol) - } - - private let noOrderHistoryLabel = UILabel().then { - $0.text = "주문 내역이 없어요" - $0.font = UIFont.appFont(.pretendardBold, size: 18) - $0.textColor = UIColor.appColor(.new500) - $0.textAlignment = .center - } - - private let seeOrderHistoryButton = UIButton( - configuration: { - var config = UIButton.Configuration.plain() - config.attributedTitle = AttributedString("과거 주문 내역 보기", attributes: .init([ - .font: UIFont.appFont(.pretendardBold, size: 13) - ])) - config.baseForegroundColor = UIColor.appColor(.neutral500) - - var background = UIBackgroundConfiguration.clear() - background.cornerRadius = 8 - background.backgroundColor = UIColor.appColor(.neutral0) - config.background = background - - config.contentInsets = NSDirectionalEdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7) - return config - }() - ).then { - $0.layer.masksToBounds = false - $0.layer.shadowColor = UIColor.black.cgColor - $0.layer.shadowOpacity = 0.2 - $0.layer.shadowOffset = CGSize(width: 0, height: 2) - $0.layer.shadowRadius = 4 - } - - override init(frame: CGRect){ - super.init(frame: frame) - configureView() - setAddTarget() - isHidden = true - - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -} - - -// MARK: - Set UI - -extension EmptyStateView { - - private func configureView() { - backgroundColor = UIColor.appColor(.newBackground) - setLayout() - } - - private func setLayout(){ - [symbolImageView, noOrderHistoryLabel, seeOrderHistoryButton].forEach{ - addSubview($0) - } - - addLayoutGuide(centerGuide) - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - guard let host = superview else { return } - - symbolImageView.snp.remakeConstraints { - $0.centerX.equalTo(host.snp.centerX) - $0.centerY.equalTo(host.snp.centerY) - $0.width.equalTo(95) - $0.height.equalTo(75) - } - - noOrderHistoryLabel.snp.remakeConstraints { - $0.top.equalTo(symbolImageView.snp.bottom).offset(16) - $0.centerX.equalTo(host.snp.centerX) - } - - seeOrderHistoryButton.snp.remakeConstraints { - $0.top.equalTo(noOrderHistoryLabel.snp.bottom).offset(16) - $0.centerX.equalTo(host.snp.centerX) - $0.height.equalTo(35) - } - } - - - - private func setAddTarget(){ - seeOrderHistoryButton.addTarget(self, action: #selector(seeOrderHistoryButtonTapped), for: .touchUpInside) - } - - func apply(_ config: Config){ - noOrderHistoryLabel.text = config.title - seeOrderHistoryButton.isHidden = !config.showSeeOrderHistoryButton - layoutIfNeeded() - } - - - //MARK: - @Objc - @objc private func seeOrderHistoryButtonTapped() { - onTapAction?() - } - - - -} diff --git a/Koin/Core/View/OrderHistoryUIComponents/FilteringButton.swift b/Koin/Core/View/OrderHistoryUIComponents/FilteringButton.swift deleted file mode 100644 index 018b3c5a..00000000 --- a/Koin/Core/View/OrderHistoryUIComponents/FilteringButton.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// FilteringButton.swift -// koin -// -// Created by 김성민 on 9/6/25. -// - -import UIKit - -final class FilteringButton: UIButton { - - private var isSelectable: Bool = true - private var forcedOn: Bool? = nil - - override init(frame: CGRect) { - super.init(frame: frame) - titleLabel?.numberOfLines = 1 - - var config = UIButton.Configuration.plain() - config.imagePlacement = .trailing - config.imagePadding = 6 - config.contentInsets = .init(top: 6, leading: 8, bottom: 6, trailing: 8) - config.attributedTitle = AttributedString("필터", attributes: .init([ - .font: UIFont.appFont(.pretendardBold, size: 14) - ])) - - var background = UIBackgroundConfiguration.clear() - background.backgroundColor = UIColor.appColor(.neutral0) - background.cornerRadius = 24 - background.strokeWidth = 0.5 - background.strokeColor = UIColor.appColor(.neutral300) - config.background = background - - config.image = UIImage.appImage(asset: .chevronDown) - config.baseForegroundColor = UIColor.appColor(.neutral500) - - self.configuration = config - - self.configurationUpdateHandler = { [weak self] button in - guard let self = self, var config = button.configuration else { return } - var background = config.background - let on = self.forcedOn ?? button.isSelected - if on { - background.backgroundColor = UIColor.appColor(.new500) - background.strokeWidth = 0 - background.strokeColor = nil - config.baseForegroundColor = UIColor.appColor(.neutral0) - } else { - background.backgroundColor = UIColor.appColor(.neutral0) - background.strokeWidth = 0.5 - background.strokeColor = UIColor.appColor(.neutral300) - config.baseForegroundColor = UIColor.appColor(.neutral500) - } - config.background = background - button.configuration = config - } - - addTarget(self, action: #selector(handleTap), for: .touchUpInside) - } - - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - func setTitle(_ text: String) { - guard var config = configuration else { return } - config.attributedTitle = AttributedString(text, attributes: .init([ - .font: UIFont.appFont(.pretendardBold, size: 14) - ])) - configuration = config - } - - func set(title: String, iconRight: UIImage? = nil, showsChevron: Bool = false) { - guard var config = configuration else { return } - config.attributedTitle = AttributedString(title, attributes: .init([ - .font: UIFont.appFont(.pretendardBold, size: 14) - ])) - if showsChevron { - config.image = UIImage.appImage(asset: .chevronDown) - } else { - config.image = iconRight?.withRenderingMode(.alwaysTemplate) - } - config.imagePlacement = .trailing - config.imagePadding = (config.image == nil) ? 0 : 6 - configuration = config - setNeedsUpdateConfiguration() - } - - func applyFilter(_ on: Bool) { - forcedOn = on - setNeedsUpdateConfiguration() - } - - func setSelectable(_ selectable: Bool) { - isSelectable = selectable - } - - @objc private func handleTap() { - if isSelectable { - isSelected.toggle() - setNeedsUpdateConfiguration() - } - } -} - diff --git a/Koin/Core/View/OrderHistoryUIComponents/OrderFloatingButton.swift b/Koin/Core/View/OrderHistoryUIComponents/OrderFloatingButton.swift deleted file mode 100644 index 5bca7eff..00000000 --- a/Koin/Core/View/OrderHistoryUIComponents/OrderFloatingButton.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// OrderFloatingButton.swift -// koin -// -// Created by 이은지 on 9/8/25. -// - -import UIKit -import Lottie -import Then -import SnapKit - -final class OrderFloatingButton: UIControl { - - // MARK: - Properties - var titleText: String? { - get { titleLabel.text } - set { titleLabel.text = newValue } - } - - var subtitleText: String? { - get { subtitleLabel.text } - set { subtitleLabel.text = newValue } - } - - var rightImage: UIImage? { - get { rightImageView.image } - set { rightImageView.image = newValue } - } - - // MARK: - UI Components - private let containerView = UIView().then { - $0.backgroundColor = .white - $0.layer.cornerRadius = 32 - $0.layer.shadowColor = UIColor.black.cgColor - $0.layer.shadowOffset = CGSize(width: 0, height: 4) - $0.layer.shadowRadius = 8 - $0.layer.shadowOpacity = 0.2 - } - - let lottieView = LottieAnimationView().then { - $0.contentMode = .scaleAspectFit - $0.loopMode = .loop - } - - private let labelStackView = UIStackView().then { - $0.axis = .vertical - $0.alignment = .leading - $0.distribution = .fillEqually - $0.spacing = 0 - } - - private let titleLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardBold, size: 16) - $0.textColor = UIColor.appColor(.new500) - } - - private let subtitleLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 12) - $0.textColor = UIColor.appColor(.neutral600) - } - - private let rightImageView = UIImageView().then { - $0.contentMode = .scaleAspectFit - $0.image = UIImage.appImage(asset: .chevronRight)?.withRenderingMode(.alwaysTemplate) - $0.tintColor = UIColor.appColor(.new500) - } - - // MARK: - Initializers - override init(frame: CGRect) { - super.init(frame: frame) - configureView() - setAddTarget() - - containerView.isUserInteractionEnabled = false - lottieView.isUserInteractionEnabled = false - labelStackView.isUserInteractionEnabled = false - rightImageView.isUserInteractionEnabled = false - - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - configureView() - setAddTarget() - } - - private func setAddTarget() { - addTarget(self, action: #selector(touchDown), for: .touchDown) - addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchUpOutside, .touchCancel]) - } - - // MARK: - Lottie Animation Methods - func setLottieAnimation(named name: String, bundle: Bundle = .main) { - lottieView.animation = LottieAnimation.named(name, bundle: bundle) - } - - func playLottieAnimation() { - lottieView.play() - } - - func stopLottieAnimation() { - lottieView.stop() - } -} - -// MARK: - @objc -extension OrderFloatingButton { - @objc private func touchDown() { - UIView.animate(withDuration: 0.1) { - self.containerView.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) - self.containerView.alpha = 0.8 - } - } - - @objc private func touchUp() { - UIView.animate(withDuration: 0.1) { - self.containerView.transform = .identity - self.containerView.alpha = 1.0 - } - } -} - -// MARK: - UI Function -extension OrderFloatingButton { - private func setUpLayout() { - addSubview(containerView) - - containerView.addSubview(lottieView) - containerView.addSubview(labelStackView) - containerView.addSubview(rightImageView) - - labelStackView.addArrangedSubview(titleLabel) - labelStackView.addArrangedSubview(subtitleLabel) - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { - $0.edges.equalToSuperview() - $0.height.equalTo(64) - } - - lottieView.snp.makeConstraints { - $0.leading.equalToSuperview().offset(6.5) - $0.centerY.equalToSuperview() - $0.width.equalTo(60) - $0.height.equalTo(57) - } - - labelStackView.snp.makeConstraints { - $0.leading.equalTo(lottieView.snp.trailing).offset(12) - $0.centerY.equalToSuperview() - $0.trailing.lessThanOrEqualTo(rightImageView.snp.leading).offset(-12) - } - - rightImageView.snp.makeConstraints { - $0.trailing.equalToSuperview().inset(14.5) - $0.centerY.equalToSuperview() - $0.width.equalTo(24) - $0.height.equalTo(12) - } - } - - private func configureView() { - setUpLayout() - setUpConstraints() - } -} - -extension OrderFloatingButton { - var lottieAnimationView: LottieAnimationView { - return lottieView - } -} diff --git a/Koin/Core/View/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift b/Koin/Core/View/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift deleted file mode 100644 index 743a464c..00000000 --- a/Koin/Core/View/OrderHistoryUIComponents/OrderHistoryCustomSearchBar.swift +++ /dev/null @@ -1,143 +0,0 @@ -// -// OrderHistoryCustomSearchBar.swift -// koin -// -// Created by 김성민 on 9/8/25. -// - -import UIKit -import SnapKit - -final class OrderHistoryCustomSearchBar: UIView { - - //MARK: - CallBack - var onTextChanged: ((String) -> Void)? - var onReturn: ((String) -> Void)? - - // MARK: - UI - private let iconView = UIImageView().then { - $0.image = UIImage.appImage(asset: .search)?.withRenderingMode(.alwaysTemplate) - $0.tintColor = UIColor.appColor(.neutral500) - $0.contentMode = .scaleAspectFit - } - - let textField = UITextField().then { - - $0.clearButtonMode = .whileEditing - $0.returnKeyType = .search - $0.autocapitalizationType = .none - $0.clearButtonMode = .never - $0.autocorrectionType = .no - $0.spellCheckingType = .no - $0.font = UIFont.appFont(.pretendardRegular, size: 14) - $0.textColor = .label - $0.attributedPlaceholder = NSAttributedString( - string: "주문한 메뉴/매장을 찾아보세요", - attributes: [ - .foregroundColor: UIColor.appColor(.neutral400), - .font: UIFont.appFont(.pretendardRegular, size: 14) - ] - ) - } - - // MARK: - Layout Config - var contentInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) { didSet { remakeConstraints() } } - var spacing: CGFloat = 8 { didSet { remakeConstraints() } } - var iconSize: CGFloat = 18 { didSet { iconView.snp.updateConstraints { $0.size.equalTo(iconSize) } } } - - override init(frame: CGRect) { - super.init(frame: frame) - setupUI() - setupActions() - setDelegate() - makeConstraints() - } - required init?(coder: NSCoder) { fatalError() } - - func setPlaceholder(_ text: String) { - textField.attributedPlaceholder = NSAttributedString( - string: text, - attributes: [ - .foregroundColor: UIColor.appColor(.neutral400), - .font: UIFont.appFont(.pretendardRegular, size: 14) - ] - ) - } - - func setLeftIcon(_ image: UIImage?, tint: UIColor? = nil) { - iconView.image = image?.withRenderingMode(.alwaysTemplate) - if let tint { iconView.tintColor = tint } - } - - @discardableResult - func focus() -> Bool { textField.becomeFirstResponder() } - func unfocus() { textField.resignFirstResponder() } - - // MARK: - Private - private func setupUI() { - backgroundColor = UIColor.appColor(.neutral0) - layer.cornerRadius = 16 - layer.masksToBounds = false - - layer.shadowColor = UIColor.black.cgColor - layer.shadowOffset = CGSize(width: 0, height: 2) - layer.shadowRadius = 4 - layer.shadowOpacity = 0.06 - - addSubview(iconView) - addSubview(textField) - } - - private func setupActions() { - textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged) - } - - private func setDelegate(){ - textField.delegate = self - } - - private func makeConstraints() { - iconView.snp.makeConstraints { - $0.size.equalTo(iconSize) - $0.leading.equalToSuperview().inset(contentInsets.left) - $0.centerY.equalToSuperview() - } - textField.snp.makeConstraints { - $0.leading.equalTo(iconView.snp.trailing).offset(spacing) - $0.top.equalToSuperview().inset(contentInsets.top) - $0.bottom.equalToSuperview().inset(contentInsets.bottom) - $0.trailing.equalToSuperview().inset(contentInsets.right) - $0.height.greaterThanOrEqualTo(28) - } - } - - private func remakeConstraints() { - iconView.snp.remakeConstraints { - $0.size.equalTo(iconSize) - $0.leading.equalToSuperview().inset(contentInsets.left) - $0.centerY.equalToSuperview() - } - textField.snp.remakeConstraints { - $0.leading.equalTo(iconView.snp.trailing).offset(spacing) - $0.top.equalToSuperview().inset(contentInsets.top) - $0.bottom.equalToSuperview().inset(contentInsets.bottom) - $0.trailing.equalToSuperview().inset(contentInsets.right) - $0.height.greaterThanOrEqualTo(28) - } - layoutIfNeeded() - } - - @objc private func textDidChange() { - onTextChanged?(textField.text ?? "") - } -} - -extension OrderHistoryCustomSearchBar: UITextFieldDelegate { - func textFieldShouldReturn(_ textField: UITextField) -> Bool { - onReturn?(textField.text ?? "") - return true - } -} - - - diff --git a/Koin/Core/View/TrackPaddedSlider.swift b/Koin/Core/View/TrackPaddedSlider.swift deleted file mode 100644 index fcfb45de..00000000 --- a/Koin/Core/View/TrackPaddedSlider.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// TrackPaddedSlider.swift -// koin -// -// Created by 이은지 on 8/8/25. -// - -import UIKit - -final class TrackPaddedSlider: UISlider { - - public var horizontalPadding: CGFloat = 0 - - override func trackRect(forBounds bounds: CGRect) -> CGRect { - let originalRect = super.trackRect(forBounds: bounds) - - let newRect = CGRect( - x: originalRect.origin.x + horizontalPadding, - y: originalRect.origin.y, - width: originalRect.size.width - (horizontalPadding * 2), - height: originalRect.size.height - ) - - return newRect - } -} diff --git a/Koin/Data/DTOs/Decodable/CallVan/CallVanPlaceDto.swift b/Koin/Data/DTOs/Decodable/CallVan/CallVanPlaceDto.swift index b26ee455..f0807da3 100644 --- a/Koin/Data/DTOs/Decodable/CallVan/CallVanPlaceDto.swift +++ b/Koin/Data/DTOs/Decodable/CallVan/CallVanPlaceDto.swift @@ -10,7 +10,6 @@ import Foundation enum CallVanPlaceDto: String, Codable { case frontGate = "FRONT_GATE" case backGate = "BACK_GATE" - case tennisCourt = "TENNIS_COURT" case dormitoryMain = "DORMITORY_MAIN" case dormitorySub = "DORMITORY_SUB" case terminal = "TERMINAL" @@ -29,7 +28,6 @@ extension CallVanPlaceDto { case .dormitorySub: self = .dormitorySub case .frontGate: self = .frontGate case .station: self = .station - case .tennisCourt: self = .tennisCourt case .terminal: self = .terminal case .all: return nil } @@ -51,8 +49,6 @@ extension CallVanPlaceDto { return .frontGate case .station: return .station - case .tennisCourt: - return .tennisCourt case .terminal: return .terminal } diff --git a/Koin/Data/DTOs/Decodable/Dining/DiningDto.swift b/Koin/Data/DTOs/Decodable/Dining/DiningDto.swift index 56505954..40d8c0dc 100644 --- a/Koin/Data/DTOs/Decodable/Dining/DiningDto.swift +++ b/Koin/Data/DTOs/Decodable/Dining/DiningDto.swift @@ -35,7 +35,20 @@ struct DiningDto: Decodable { } func toDomain() -> DiningItem { - return .init(id: id, type: type, place: place, priceCard: priceCard, priceCash: priceCash, kcal: kcal ?? 0, menu: menu ?? [], soldoutAt: soldoutAt, changedAt: changedAt, imageUrl: imageURL, likes: likes, isLiked: isLiked, date: date + return .init( + id: id, + type: type, + place: place, + priceCard: priceCard, + priceCash: priceCash, + kcal: kcal ?? 0, + menu: menu ?? [], + soldoutAt: soldoutAt, + changedAt: changedAt, + imageUrl: imageURL, + likes: likes, + isLiked: isLiked, + date: date ) } } @@ -50,7 +63,16 @@ enum DiningType: String, Decodable { let rawValue = try container.decode(String.self) self = DiningType(rawValue: rawValue) ?? .breakfast } - + + init?(segmentIndex: Int) { + switch segmentIndex { + case 0: self = .breakfast + case 1: self = .lunch + case 2: self = .dinner + default: return nil + } + } + var name: String { switch self { case .breakfast : "아침" diff --git a/Koin/Data/DTOs/Decodable/Chat/ChatDetailDto.swift b/Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemChatDetailDto.swift similarity index 71% rename from Koin/Data/DTOs/Decodable/Chat/ChatDetailDto.swift rename to Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemChatDetailDto.swift index e53f8941..05f7fbc2 100644 --- a/Koin/Data/DTOs/Decodable/Chat/ChatDetailDto.swift +++ b/Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemChatDetailDto.swift @@ -1,5 +1,5 @@ // -// ChatDetailDto.swift +// LostItemChatDetailDto.swift // koin // // Created by 김나훈 on 2/18/25. @@ -7,7 +7,7 @@ import Foundation -struct ChatDetailDto: Codable { +struct LostItemChatDetailDto: Codable { let userId: Int let userNickname, content, timestamp: String let isImage: Bool @@ -20,15 +20,14 @@ struct ChatDetailDto: Codable { } } -extension ChatDetailDto { - func toDomain(currentUserId: Int) -> ChatMessage { - return ChatMessage( +extension LostItemChatDetailDto { + func toDomain(currentUserId: Int) -> LostItemChatMessage { + return LostItemChatMessage( senderNickname: userNickname, content: content, timestamp: timestamp, isImage: isImage, - isMine: userId == currentUserId, chatDateInfo: timestamp.toChatDateInfo() + isMine: userId == currentUserId, chatDateInfo: timestamp.toLostItemChatDateInfo() ) } } - diff --git a/Koin/Data/DTOs/Decodable/Chat/ChatRoomDto.swift b/Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemChatRoomDto.swift similarity index 81% rename from Koin/Data/DTOs/Decodable/Chat/ChatRoomDto.swift rename to Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemChatRoomDto.swift index 151bfb23..ca1c2276 100644 --- a/Koin/Data/DTOs/Decodable/Chat/ChatRoomDto.swift +++ b/Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemChatRoomDto.swift @@ -1,5 +1,5 @@ // -// ChatRoomDto.swift +// LostItemChatRoomDto.swift // koin // // Created by 김나훈 on 2/18/25. @@ -7,7 +7,7 @@ import Foundation -struct ChatRoomDto: Codable { +struct LostItemChatRoomDto: Codable { let articleTitle, recentMessageContent: String let lostItemImageUrl: String? let unreadMessageCount: Int @@ -24,16 +24,16 @@ struct ChatRoomDto: Codable { case chatRoomId = "chat_room_id" } } -extension ChatRoomDto { - func toDomain() -> ChatRoomItem { +extension LostItemChatRoomDto { + func toDomain() -> LostItemChatRoomItem { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return ChatRoomItem( + return LostItemChatRoomItem( articleTitle: articleTitle, recentMessageContent: recentMessageContent, lostItemImageUrl: lostItemImageUrl, unreadMessageCount: unreadMessageCount, lastMessageAt: lastMessageAt, - chatDateInfo: lastMessageAt.toChatDateInfo(), + chatDateInfo: lastMessageAt.toLostItemChatDateInfo(), articleId: articleId, chatRoomId: chatRoomId ) diff --git a/Koin/Data/DTOs/Decodable/Chat/CreateChatRoomResponse.swift b/Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemCreateChatRoomResponse.swift similarity index 84% rename from Koin/Data/DTOs/Decodable/Chat/CreateChatRoomResponse.swift rename to Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemCreateChatRoomResponse.swift index ddf3ccf8..1c54f348 100644 --- a/Koin/Data/DTOs/Decodable/Chat/CreateChatRoomResponse.swift +++ b/Koin/Data/DTOs/Decodable/LostItem/LostItemChat/LostItemCreateChatRoomResponse.swift @@ -1,5 +1,5 @@ // -// CreateCharRoomResponse.swift +// LostItemCreateChatRoomResponse.swift // koin // // Created by 김나훈 on 2/18/25. @@ -7,7 +7,7 @@ import Foundation -struct CreateChatRoomResponse: Decodable { +struct LostItemCreateChatRoomResponse: Decodable { let articleId: Int let chatRoomId: Int let userId: Int diff --git a/Koin/Data/DTOs/Decodable/NoticeList/NoticeAISummaryDto.swift b/Koin/Data/DTOs/Decodable/NoticeList/NoticeAISummaryDto.swift new file mode 100644 index 00000000..bd140879 --- /dev/null +++ b/Koin/Data/DTOs/Decodable/NoticeList/NoticeAISummaryDto.swift @@ -0,0 +1,52 @@ +// +// NoticeAISummaryDto.swift +// koin +// +// Created by 홍기정 on 8/13/26. +// + +import Foundation + +struct NoticeAISummaryDto: Decodable { + let status: NoticeAISummaryStatusDto + let items: [NoticeAISummaryItemDto] +} + +struct NoticeAISummaryItemDto: Decodable { + let icon: String + let text: String +} + +enum NoticeAISummaryStatusDto: String, Decodable { + case success = "SUCCESS" + case pending = "PENDING" + case unavailable = "UNAVAILABLE" +} + +extension NoticeAISummaryDto { + func toDomain() -> NoticeAISummary { + NoticeAISummary( + status: status.toDomain(), + items: items.map { $0.toDomain() } + ) + } +} + +extension NoticeAISummaryItemDto { + func toDomain() -> NoticeAISummaryItem { + NoticeAISummaryItem( + icon: icon, + text: text + ) + } +} + +extension NoticeAISummaryStatusDto { + func toDomain() -> NoticeAISummaryStatus { + switch self { + case .success: .success + case .pending: .pending + case .unavailable: .unavailable + } + } +} diff --git a/Koin/Data/DTOs/Decodable/NoticeList/NoticeListDto.swift b/Koin/Data/DTOs/Decodable/NoticeList/NoticeListDto.swift index 16419550..2fa8ea8d 100644 --- a/Koin/Data/DTOs/Decodable/NoticeList/NoticeListDto.swift +++ b/Koin/Data/DTOs/Decodable/NoticeList/NoticeListDto.swift @@ -26,6 +26,7 @@ struct NoticeArticleDto: Decodable { let boardId: Int let title: String? let content: String? + let aiSummary: NoticeAISummaryDto? let author: String? let hit: Int? let url: String? @@ -39,6 +40,7 @@ struct NoticeArticleDto: Decodable { case id case boardId = "board_id" case title, content, author, hit, url, attachments + case aiSummary = "ai_summary" case prevId = "prev_id" case nextId = "next_id" case registeredAt = "registered_at" @@ -71,7 +73,19 @@ extension NoticeListDto { extension NoticeArticleDto { func toDomain() -> NoticeDataInfo { - return NoticeDataInfo(title: title ?? "", boardId: boardId, content: content ?? "", author: author ?? "-", hit: hit, prevId: prevId, nextId: nextId, attachments: attachments ?? [], url: url, registeredAt: registeredAt) + return NoticeDataInfo( + title: title ?? "", + boardId: boardId, + aiSummary: aiSummary?.toDomain() ?? NoticeAISummary(status: .unavailable, items: []), + content: content ?? "", + author: author ?? "-", + hit: hit, + prevId: prevId, + nextId: nextId, + attachments: attachments ?? [], + url: url, + registeredAt: registeredAt + ) } func toDomainWithChangedDate() -> NoticeArticleDto { @@ -86,6 +100,7 @@ extension NoticeArticleDto { boardId: boardId, title: newTitle, content: modifyFontInHtml(html: content ?? ""), + aiSummary: aiSummary, author: author, hit: hit, url: url, diff --git a/Koin/Data/DTOs/Encodable/Chat/PostChatDetailRequest.swift b/Koin/Data/DTOs/Encodable/LostItem/LostItemChat/LostItemPostChatDetailRequest.swift similarity index 77% rename from Koin/Data/DTOs/Encodable/Chat/PostChatDetailRequest.swift rename to Koin/Data/DTOs/Encodable/LostItem/LostItemChat/LostItemPostChatDetailRequest.swift index b16a31eb..38dddc0d 100644 --- a/Koin/Data/DTOs/Encodable/Chat/PostChatDetailRequest.swift +++ b/Koin/Data/DTOs/Encodable/LostItem/LostItemChat/LostItemPostChatDetailRequest.swift @@ -1,5 +1,5 @@ // -// PostChatDetailRequest.swift +// LostItemPostChatDetailRequest.swift // koin // // Created by 홍기정 on 1/28/26. @@ -7,7 +7,7 @@ import Foundation -struct PostChatDetailRequest: Encodable { +struct LostItemPostChatDetailRequest: Encodable { let userNickname: String let content: String diff --git a/Koin/Data/Repository/DefaultChatRepository.swift b/Koin/Data/Repository/DefaultChatRepository.swift deleted file mode 100644 index 48fdc3ce..00000000 --- a/Koin/Data/Repository/DefaultChatRepository.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// DefaultChatRepository.swift -// koin -// -// Created by 김나훈 on 2/18/25. -// - -import Combine - -final class DefaultChatRepository: ChatRepository { - - private let service: ChatService - - init(service: ChatService) { - self.service = service - } - - func createChatRoom(articleId: Int) -> AnyPublisher { - service.createChatRoom(articleId: articleId) - } - - func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher { - service.blockUser(articleId: articleId, chatRoomId: chatRoomId) - } - - func fetchChatRoom() -> AnyPublisher<[ChatRoomDto], ErrorResponse> { - service.fetchChatRoom() - } - - func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[ChatDetailDto], ErrorResponse> { - service.fetchChatDetail(articleId: articleId, chatRoomId: chatRoomId) - } - - func postChatDetail(articleId: Int, chatRoomId: Int, request: PostChatDetailRequest) -> AnyPublisher { - service.postChatDetail(articleId: articleId, chatRoomId: chatRoomId, request: request) - } -} diff --git a/Koin/Data/Repository/DefaultLostItemRepository.swift b/Koin/Data/Repository/DefaultLostItemRepository.swift index cd3b8fd0..745d6915 100644 --- a/Koin/Data/Repository/DefaultLostItemRepository.swift +++ b/Koin/Data/Repository/DefaultLostItemRepository.swift @@ -79,4 +79,24 @@ final class DefaultLostItemRepository: LostItemRepository { func unsubscribeKeyword(id: Int) -> AnyPublisher { return service.unsubscribeKeyword(id: id) } + + func createChatRoom(articleId: Int) -> AnyPublisher { + service.createChatRoom(articleId: articleId) + } + + func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher { + service.blockUser(articleId: articleId, chatRoomId: chatRoomId) + } + + func fetchChatRoom() -> AnyPublisher<[LostItemChatRoomDto], ErrorResponse> { + service.fetchChatRoom() + } + + func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[LostItemChatDetailDto], ErrorResponse> { + service.fetchChatDetail(articleId: articleId, chatRoomId: chatRoomId) + } + + func postChatDetail(articleId: Int, chatRoomId: Int, request: LostItemPostChatDetailRequest) -> AnyPublisher { + service.postChatDetail(articleId: articleId, chatRoomId: chatRoomId, request: request) + } } diff --git a/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift b/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift index 49acccc8..889c0dcf 100644 --- a/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift +++ b/Koin/Data/Repository/DefaultNotificationHistoryRepository.swift @@ -15,10 +15,10 @@ final class DefaultNotificationHistoryRepository: NotificationHistoryRepository self.service = service } - func fetchAll() async throws -> [NotificationItem] { + func fetchAll() async throws -> [NotificationHistoryItem] { try await service.fetchAll() .compactMap { - NotificationItem.init(from: $0) + NotificationHistoryItem.init(from: $0) } } diff --git a/Koin/Data/Service/ChatService.swift b/Koin/Data/Service/ChatService.swift deleted file mode 100644 index ea5479bc..00000000 --- a/Koin/Data/Service/ChatService.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ChatService.swift -// koin -// -// Created by 김나훈 on 2/18/25. -// - -import Alamofire -import Combine - -protocol ChatService { - func fetchChatRoom() -> AnyPublisher<[ChatRoomDto], ErrorResponse> - func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[ChatDetailDto], ErrorResponse> - func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher - func createChatRoom(articleId: Int) -> AnyPublisher - func postChatDetail(articleId: Int, chatRoomId: Int, request: PostChatDetailRequest) -> AnyPublisher -} - -final class DefaultChatService: ChatService { - - private let networkService = NetworkService.shared - - func createChatRoom(articleId: Int) -> AnyPublisher { - return networkService.requestWithResponse(api: ChatAPI.createChatRoom(articleId)) - } - - func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher { - return networkService.request(api: ChatAPI.blockUser(articleId, chatRoomId)) - } - - func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[ChatDetailDto], ErrorResponse> { - return networkService.requestWithResponse(api: ChatAPI.fetchChatDetail(articleId, chatRoomId)) - } - - func fetchChatRoom() -> AnyPublisher<[ChatRoomDto], ErrorResponse> { - return networkService.requestWithResponse(api: ChatAPI.fetchChatRoom) - } - - func postChatDetail(articleId: Int, chatRoomId: Int, request: PostChatDetailRequest) -> AnyPublisher { - return networkService.requestWithResponse(api: ChatAPI.postChatDetail(articleId, chatRoomId, request)) - } -} diff --git a/Koin/Data/Service/LostItemService.swift b/Koin/Data/Service/LostItemService.swift index 3c899b50..dfe98bf3 100644 --- a/Koin/Data/Service/LostItemService.swift +++ b/Koin/Data/Service/LostItemService.swift @@ -23,6 +23,12 @@ protocol LostItemService { func fetchKeywordSuggestion() -> AnyPublisher func fetchMyKeyword() -> AnyPublisher func unsubscribeKeyword(id: Int) -> AnyPublisher + + func fetchChatRoom() -> AnyPublisher<[LostItemChatRoomDto], ErrorResponse> + func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[LostItemChatDetailDto], ErrorResponse> + func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher + func createChatRoom(articleId: Int) -> AnyPublisher + func postChatDetail(articleId: Int, chatRoomId: Int, request: LostItemPostChatDetailRequest) -> AnyPublisher } final class DefaultLostItemService: LostItemService { @@ -76,4 +82,24 @@ final class DefaultLostItemService: LostItemService { func unsubscribeKeyword(id: Int) -> AnyPublisher { return networkService.request(api: LostItemAPI.unsubscribeKeyword(id)) } + + func createChatRoom(articleId: Int) -> AnyPublisher { + return networkService.requestWithResponse(api: LostItemAPI.createChatRoom(articleId)) + } + + func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher { + return networkService.request(api: LostItemAPI.blockUser(articleId, chatRoomId)) + } + + func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[LostItemChatDetailDto], ErrorResponse> { + return networkService.requestWithResponse(api: LostItemAPI.fetchChatDetail(articleId, chatRoomId)) + } + + func fetchChatRoom() -> AnyPublisher<[LostItemChatRoomDto], ErrorResponse> { + return networkService.requestWithResponse(api: LostItemAPI.fetchChatRoom) + } + + func postChatDetail(articleId: Int, chatRoomId: Int, request: LostItemPostChatDetailRequest) -> AnyPublisher { + return networkService.requestWithResponse(api: LostItemAPI.postChatDetail(articleId, chatRoomId, request)) + } } diff --git a/Koin/Data/Service/Network/API/ChatAPI.swift b/Koin/Data/Service/Network/API/ChatAPI.swift deleted file mode 100644 index 9f651217..00000000 --- a/Koin/Data/Service/Network/API/ChatAPI.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// ChatAPI.swift -// koin -// -// Created by 김나훈 on 2/18/25. -// - -import Foundation -import Alamofire - -enum ChatAPI { - case fetchChatRoom - case fetchChatDetail(Int, Int) - case blockUser(Int, Int) - case createChatRoom(Int) - case postChatDetail(Int, Int, PostChatDetailRequest) -} - -extension ChatAPI: Router, URLRequestConvertible { - - public var baseURL: String { - return Bundle.main.baseUrl - } - - public var path: String { - switch self { - case .fetchChatRoom: return "/chatroom/lost-item" - case .fetchChatDetail(let articleId, let chatRoomId): return "/chatroom/lost-item/\(articleId)/\(chatRoomId)/messages" - case .blockUser(let articleId, let chatRoomId): return "/chatroom/lost-item/\(articleId)/\(chatRoomId)/block" - case .createChatRoom(let articleId): return "/chatroom/lost-item/\(articleId)" - case .postChatDetail(let articleId, let chatRoomId, _): return "/v2/chatroom/lost-item/\(articleId)/\(chatRoomId)/messages" - } - } - - public var method: Alamofire.HTTPMethod { - switch self { - case .fetchChatRoom, .fetchChatDetail: return .get - case .blockUser, .createChatRoom, .postChatDetail: return .post - } - } - - public var headers: [String: String] { - var baseHeaders: [String: String] = [:] - switch self { - case .createChatRoom, .postChatDetail: - baseHeaders["Content-Type"] = "application/json" - default: break - } - return baseHeaders - } - - public var parameters: Any? { - switch self { - case .fetchChatRoom, .fetchChatDetail, .blockUser, .createChatRoom: - return nil - case .postChatDetail(_, _, let request): - return try? JSONEncoder().encode(request) - } - } - - public var encoding: ParameterEncoding? { - switch self { - case .fetchChatRoom: return URLEncoding.default - case .postChatDetail: return JSONEncoding.default - case .fetchChatDetail: return nil - case .blockUser: return nil - case .createChatRoom: return nil - } - } -} diff --git a/Koin/Data/Service/Network/API/LostItemAPI.swift b/Koin/Data/Service/Network/API/LostItemAPI.swift index d9505603..95c80c49 100644 --- a/Koin/Data/Service/Network/API/LostItemAPI.swift +++ b/Koin/Data/Service/Network/API/LostItemAPI.swift @@ -22,6 +22,12 @@ enum LostItemAPI { case fetchKeywordSuggestion case fetchMyKeyword case unsubscribeKeyword(Int) + + case fetchChatRoom + case fetchChatDetail(Int, Int) + case blockUser(Int, Int) + case createChatRoom(Int) + case postChatDetail(Int, Int, LostItemPostChatDetailRequest) } extension LostItemAPI: Router, URLRequestConvertible { @@ -44,6 +50,12 @@ extension LostItemAPI: Router, URLRequestConvertible { case .fetchKeywordSuggestion: return "/articles/keyword/suggestions?type=LOST_ITEM" case .fetchMyKeyword: return "/articles/keyword/me?type=LOST_ITEM" case .unsubscribeKeyword(let id): return "/articles/keyword/\(id)" + + case .fetchChatRoom: return "/chatroom/lost-item" + case .fetchChatDetail(let articleId, let chatRoomId): return "/chatroom/lost-item/\(articleId)/\(chatRoomId)/messages" + case .blockUser(let articleId, let chatRoomId): return "/chatroom/lost-item/\(articleId)/\(chatRoomId)/block" + case .createChatRoom(let articleId): return "/chatroom/lost-item/\(articleId)" + case .postChatDetail(let articleId, let chatRoomId, _): return "/v2/chatroom/lost-item/\(articleId)/\(chatRoomId)/messages" } } @@ -62,11 +74,19 @@ extension LostItemAPI: Router, URLRequestConvertible { case .fetchKeywordSuggestion: return .get case .fetchMyKeyword: return .get case .unsubscribeKeyword: return .delete + + case .fetchChatRoom, .fetchChatDetail: return .get + case .blockUser, .createChatRoom, .postChatDetail: return .post } } public var headers: [String: String] { - return [:] + switch self { + case .createChatRoom, .postChatDetail: + return ["Content-Type": "application/json"] + default: + return [:] + } } public var parameters: Any? { @@ -86,6 +106,11 @@ extension LostItemAPI: Router, URLRequestConvertible { return try? request.toDictionary() case .fetchKeywordSuggestion, .fetchMyKeyword, .unsubscribeKeyword: return nil + + case .fetchChatRoom, .fetchChatDetail, .blockUser, .createChatRoom: + return nil + case .postChatDetail(_, _, let request): + return try? JSONEncoder().encode(request) } } @@ -104,6 +129,10 @@ extension LostItemAPI: Router, URLRequestConvertible { return JSONEncoding.default case .fetchKeywordSuggestion, .fetchMyKeyword, .unsubscribeKeyword: return nil + + case .fetchChatRoom: return URLEncoding.default + case .postChatDetail: return JSONEncoding.default + case .fetchChatDetail, .blockUser, .createChatRoom: return nil } } } diff --git a/Koin/Data/Service/Network/API/NoticeListAPI.swift b/Koin/Data/Service/Network/API/NoticeListAPI.swift index 5a3d84ae..dd4e086f 100644 --- a/Koin/Data/Service/Network/API/NoticeListAPI.swift +++ b/Koin/Data/Service/Network/API/NoticeListAPI.swift @@ -30,7 +30,7 @@ extension NoticeListAPI: Router, URLRequestConvertible { switch self { case .fetchNoticeArticles: return "/articles" case .searchNoticeArticle: return "/articles/search" - case .fetchNoticeData(let request): return "/articles/\(request.noticeId)" + case .fetchNoticeData(let request): return "/v2/articles/\(request.noticeId)" case .fetchHotNoticeArticles: return "/articles/hot" case .createNotificationKeyword: return "/articles/keyword?type=KOREATECH" case .deleteNotificationKeyword(let request): return "/articles/keyword/\(request)" diff --git a/Koin/Domain/Model/CallVan/CallVanListRequest.swift b/Koin/Domain/Model/CallVan/CallVanListRequest.swift index c1571438..e865583d 100644 --- a/Koin/Domain/Model/CallVan/CallVanListRequest.swift +++ b/Koin/Domain/Model/CallVan/CallVanListRequest.swift @@ -26,10 +26,26 @@ enum CallVanListSort: String, CallVanFilterState { case departureDesc = "출발시각순" case latestAsc = "과거순" case latestDesc = "최신순" + + var index: Int? { + switch self { + case .latestDesc: 0 + case .departureDesc: 1 + default: nil + } + } } enum CallVanMineOrJoined: String, CallVanFilterState { case all = "전체" case mine = "내 게시물" case joined = "참여중인 게시물" + + var index: Int { + switch self { + case .all: 0 + case .mine: 1 + case .joined: 2 + } + } } diff --git a/Koin/Domain/Model/CallVan/CallVanPlace.swift b/Koin/Domain/Model/CallVan/CallVanPlace.swift index 2a4d7f77..c6c100d7 100644 --- a/Koin/Domain/Model/CallVan/CallVanPlace.swift +++ b/Koin/Domain/Model/CallVan/CallVanPlace.swift @@ -7,16 +7,36 @@ import Foundation -enum CallVanPlace: String, CallVanFilterState { +enum CallVanPlace: String, CallVanFilterState, CaseIterable { + case all = "전체" + case frontGate = "정문" case backGate = "후문" - case tennisCourt = "테니스장" + case terminal = "천안터미널" + case dormitoryMain = "본관동" case dormitorySub = "별관동" - case terminal = "천안터미널" case station = "천안역" case asanStation = "천안아산역" + case custom = "기타" + + var index: Int { + switch self { + case .all: 0 + + case .frontGate: 1 + case .backGate: 2 + case .terminal: 3 + + case .dormitoryMain: 4 + case .dormitorySub: 5 + case .station: 6 + case .asanStation: 7 + + case .custom: 8 + } + } } diff --git a/Koin/Domain/Model/CallVan/CallVanRecruitmentState.swift b/Koin/Domain/Model/CallVan/CallVanRecruitmentState.swift index 049bd701..c8e1f7f3 100644 --- a/Koin/Domain/Model/CallVan/CallVanRecruitmentState.swift +++ b/Koin/Domain/Model/CallVan/CallVanRecruitmentState.swift @@ -11,4 +11,12 @@ enum CallVanRecruitmentState: String, CallVanFilterState { case all = "전체" case recruiting = "모집중" case closed = "모집마감" + + var index: Int { + switch self { + case .all: 0 + case .recruiting: 1 + case .closed: 2 + } + } } diff --git a/Koin/Domain/Model/Home/CategoryModels.swift b/Koin/Domain/Model/Home/CategoryModels.swift index 2db31fa0..41031cc0 100644 --- a/Koin/Domain/Model/Home/CategoryModels.swift +++ b/Koin/Domain/Model/Home/CategoryModels.swift @@ -16,9 +16,11 @@ enum HomeCategoryItem: Identifiable { case busTimetable case busRoute case callVan + case chat case land case business case department + case recruit var id: String { title } @@ -42,17 +44,21 @@ enum HomeCategoryItem: Identifiable { return "교통편 조회하기" case .callVan: return "콜밴팟 모집" + case .chat: + return "채팅" case .land: return "복덕방" case .business: return "코인 for Business" + case .recruit: + return "팀원모집" } } var subtitle: String? { switch self { - case .timetable: - return "내 강의 정보 확인하기" + case .recruit: + return "교내 활동 팀원 구하기" case .lostItem: return "분실물 신고 / 조회하기" default: @@ -80,10 +86,14 @@ enum HomeCategoryItem: Identifiable { return .categoryBusSearch case .callVan: return .categoryCallVan + case .chat: + return .categoryChat case .land: return .categoryLand case .business: return .categoryBusiness + case .recruit: + return .categoryRecruit } } } diff --git a/Koin/Domain/Model/Home/NotificationItem.swift b/Koin/Domain/Model/Home/NotificationHistoryItem.swift similarity index 68% rename from Koin/Domain/Model/Home/NotificationItem.swift rename to Koin/Domain/Model/Home/NotificationHistoryItem.swift index 5cd986f1..1a42978c 100644 --- a/Koin/Domain/Model/Home/NotificationItem.swift +++ b/Koin/Domain/Model/Home/NotificationHistoryItem.swift @@ -1,5 +1,5 @@ // -// NotificationItem.swift +// NotificationHistoryItem.swift // koin // // Created by 홍기정 on 6/3/26. @@ -7,7 +7,7 @@ import Foundation -struct NotificationItem { +struct NotificationHistoryItem { let id: String var isRead: Bool let icon: ImageAsset @@ -18,10 +18,28 @@ struct NotificationItem { let dateText: String } -extension NotificationItem { - - init?(from record: NotificationRecord) { - guard let icon = NotificationItem.icon(for: record.category) else { +extension NotificationHistoryItem { + var logValue: String? { + switch appPath { + case .shop: + return "주변상점" + case .dining: + return "식단" + case .keyword: + return "키워드알림" + case .chat: + return "분실물 채팅" + case .callvan: + return "콜밴팟" + case .callvanChat: + return "콜밴팟 채팅" + default: + return nil + } + } + + init?(from record: NotificationHistoryRecord) { + guard let icon = NotificationHistoryItem.icon(for: record.category) else { return nil } self.id = record.messageId @@ -31,9 +49,9 @@ extension NotificationItem { self.uri = record.schemeUri self.title = record.title self.content = record.body - self.dateText = NotificationItem.dateText(for: record.createdAt) + self.dateText = NotificationHistoryItem.dateText(for: record.createdAt) } - + static func icon(for appPath: AppPath) -> ImageAsset? { switch appPath { case .shop: @@ -52,7 +70,7 @@ extension NotificationItem { return nil } } - + static func dateText(for createdAt: Date) -> String { let now = Date() let compareComponents = Calendar.current.dateComponents( @@ -60,7 +78,7 @@ extension NotificationItem { from: createdAt, to: now ) - + if let day = compareComponents.day, 1 <= day { return "\(day)일 전" } diff --git a/Koin/Domain/Model/Chat/ChatDateInfo.swift b/Koin/Domain/Model/LostItem/LostItemChatDateInfo.swift similarity index 81% rename from Koin/Domain/Model/Chat/ChatDateInfo.swift rename to Koin/Domain/Model/LostItem/LostItemChatDateInfo.swift index 41458352..a287b858 100644 --- a/Koin/Domain/Model/Chat/ChatDateInfo.swift +++ b/Koin/Domain/Model/LostItem/LostItemChatDateInfo.swift @@ -1,5 +1,5 @@ // -// ChatDateInfo.swift +// LostItemChatDateInfo.swift // koin // // Created by 김나훈 on 2/20/25. @@ -7,7 +7,7 @@ import Foundation -struct ChatDateInfo { +struct LostItemChatDateInfo { let year: Int let month: Int let day: Int diff --git a/Koin/Domain/Model/Chat/ChatHistoryData.swift b/Koin/Domain/Model/LostItem/LostItemChatHistoryData.swift similarity index 65% rename from Koin/Domain/Model/Chat/ChatHistoryData.swift rename to Koin/Domain/Model/LostItem/LostItemChatHistoryData.swift index 83c4e37d..e79f75ff 100644 --- a/Koin/Domain/Model/Chat/ChatHistoryData.swift +++ b/Koin/Domain/Model/LostItem/LostItemChatHistoryData.swift @@ -1,5 +1,5 @@ // -// ChatHistoryData.swift +// LostItemChatHistoryData.swift // koin // // Created by 김나훈 on 2/20/25. @@ -7,11 +7,11 @@ import Foundation -struct ChatMessage { +struct LostItemChatMessage { let senderNickname: String let content: String let timestamp: String let isImage: Bool let isMine: Bool - let chatDateInfo: ChatDateInfo + let chatDateInfo: LostItemChatDateInfo } diff --git a/Koin/Domain/Model/Chat/ChatRoomItem.swift b/Koin/Domain/Model/LostItem/LostItemChatRoomItem.swift similarity index 73% rename from Koin/Domain/Model/Chat/ChatRoomItem.swift rename to Koin/Domain/Model/LostItem/LostItemChatRoomItem.swift index 65d68671..21016013 100644 --- a/Koin/Domain/Model/Chat/ChatRoomItem.swift +++ b/Koin/Domain/Model/LostItem/LostItemChatRoomItem.swift @@ -1,5 +1,5 @@ // -// ChatRoomItem.swift +// LostItemChatRoomItem.swift // koin // // Created by 김나훈 on 2/18/25. @@ -7,13 +7,13 @@ import Foundation -struct ChatRoomItem { +struct LostItemChatRoomItem { let articleTitle: String let recentMessageContent: String let lostItemImageUrl: String? let unreadMessageCount: Int let lastMessageAt: String - let chatDateInfo: ChatDateInfo + let chatDateInfo: LostItemChatDateInfo let articleId: Int let chatRoomId: Int } diff --git a/Koin/Domain/Model/NoticeList/NoticeAISummary.swift b/Koin/Domain/Model/NoticeList/NoticeAISummary.swift new file mode 100644 index 00000000..5ac28fb1 --- /dev/null +++ b/Koin/Domain/Model/NoticeList/NoticeAISummary.swift @@ -0,0 +1,44 @@ +// +// NoticeAISummary.swift +// koin +// +// Created by 홍기정 on 8/11/26. +// + +import Foundation + +struct NoticeAISummary { + let status: NoticeAISummaryStatus + let items: [NoticeAISummaryItem] +} + +struct NoticeAISummaryItem: Identifiable { + var id: String { + icon + text + } + + let icon: String + let text: String +} + +enum NoticeAISummaryStatus { + case loading + case success + case pending + case unavailable +} + +extension NoticeAISummaryItem { + var attributedString: AttributedString { + var attributedString = AttributedString("\(icon) \(text)") + + if let iconRange = attributedString.range(of: icon) { + attributedString[iconRange].font = .system(size: 14) + } + if let textRange = attributedString.range(of: text) { + attributedString[textRange].font = .appFont(.pretendardRegular, size: 14) + } + + return attributedString + } +} diff --git a/Koin/Domain/Model/NoticeList/NoticeDataInfo.swift b/Koin/Domain/Model/NoticeList/NoticeDataInfo.swift index 7471f20a..4e5914c1 100644 --- a/Koin/Domain/Model/NoticeList/NoticeDataInfo.swift +++ b/Koin/Domain/Model/NoticeList/NoticeDataInfo.swift @@ -10,6 +10,7 @@ import Foundation struct NoticeDataInfo { let title: String let boardId: Int + let aiSummary: NoticeAISummary let content: String let author: String let hit: Int? diff --git a/Koin/Domain/Repository/ChatRepository.swift b/Koin/Domain/Repository/ChatRepository.swift deleted file mode 100644 index ea09f708..00000000 --- a/Koin/Domain/Repository/ChatRepository.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// ChatRepository.swift -// koin -// -// Created by 김나훈 on 2/18/25. -// - -import Combine - -protocol ChatRepository { - func fetchChatRoom() -> AnyPublisher<[ChatRoomDto], ErrorResponse> - func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[ChatDetailDto], ErrorResponse> - func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher - func createChatRoom(articleId: Int) -> AnyPublisher - func postChatDetail(articleId: Int, chatRoomId: Int, request: PostChatDetailRequest) -> AnyPublisher -} diff --git a/Koin/Domain/Repository/LostItemRepository.swift b/Koin/Domain/Repository/LostItemRepository.swift index d83f3323..f01c8aff 100644 --- a/Koin/Domain/Repository/LostItemRepository.swift +++ b/Koin/Domain/Repository/LostItemRepository.swift @@ -23,4 +23,10 @@ protocol LostItemRepository { func fetchKeywordSuggestion() -> AnyPublisher<[String], ErrorResponse> func fetchMyKeyword() -> AnyPublisher func unsubscribeKeyword(id: Int) -> AnyPublisher + + func fetchChatRoom() -> AnyPublisher<[LostItemChatRoomDto], ErrorResponse> + func fetchChatDetail(articleId: Int, chatRoomId: Int) -> AnyPublisher<[LostItemChatDetailDto], ErrorResponse> + func blockUser(articleId: Int, chatRoomId: Int) -> AnyPublisher + func createChatRoom(articleId: Int) -> AnyPublisher + func postChatDetail(articleId: Int, chatRoomId: Int, request: LostItemPostChatDetailRequest) -> AnyPublisher } diff --git a/Koin/Domain/Repository/NotificationHistoryRepository.swift b/Koin/Domain/Repository/NotificationHistoryRepository.swift index 4ef807fa..2937bc1c 100644 --- a/Koin/Domain/Repository/NotificationHistoryRepository.swift +++ b/Koin/Domain/Repository/NotificationHistoryRepository.swift @@ -8,7 +8,7 @@ import Combine protocol NotificationHistoryRepository { - func fetchAll() async throws -> [NotificationItem] + func fetchAll() async throws -> [NotificationHistoryItem] func deleteAll() async throws func delete(id: String) async throws func markAsRead(id: String) async throws diff --git a/Koin/Domain/UseCase/Chat/CreateChatRoomUseCase.swift b/Koin/Domain/UseCase/Chat/CreateChatRoomUseCase.swift deleted file mode 100644 index 9ba32675..00000000 --- a/Koin/Domain/UseCase/Chat/CreateChatRoomUseCase.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// CreateChatRoomUseCase.swift -// koin -// -// Created by 김나훈 on 2/18/25. -// - -import Combine - -protocol CreateChatRoomUseCase { - func execute(articleId: Int) -> AnyPublisher -} -final class DefaultCreateChatRoomUseCase: CreateChatRoomUseCase { - - private let chatRepository: ChatRepository - - init(chatRepository: ChatRepository) { - self.chatRepository = chatRepository - } - - func execute(articleId: Int) -> AnyPublisher { - return chatRepository.createChatRoom(articleId: articleId) - } -} - diff --git a/Koin/Domain/UseCase/Chat/FetchChatRoomUseCase.swift b/Koin/Domain/UseCase/Chat/FetchChatRoomUseCase.swift deleted file mode 100644 index 6d12bfb6..00000000 --- a/Koin/Domain/UseCase/Chat/FetchChatRoomUseCase.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// FetchChatRoomUseCase.swift -// koin -// -// Created by 김나훈 on 2/18/25. -// - -import Combine - -protocol FetchChatRoomUseCase { - func execute() -> AnyPublisher<[ChatRoomItem], ErrorResponse> -} - -final class DefaultFetchChatRoomUseCase: FetchChatRoomUseCase { - private let chatRepository: ChatRepository - - init(chatRepository: ChatRepository) { - self.chatRepository = chatRepository - } - - func execute() -> AnyPublisher<[ChatRoomItem], ErrorResponse> { - return chatRepository.fetchChatRoom() - .map { $0.map { $0.toDomain() } } - .eraseToAnyPublisher() - } -} diff --git a/Koin/Domain/UseCase/Chat/PostChatDetailUseCase.swift b/Koin/Domain/UseCase/Chat/PostChatDetailUseCase.swift deleted file mode 100644 index 04bcd860..00000000 --- a/Koin/Domain/UseCase/Chat/PostChatDetailUseCase.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PostChatDetailUseCase.swift -// koin -// -// Created by 홍기정 on 1/28/26. -// - -import Foundation -import Combine - -protocol PostChatDetailUseCase { - func execute(articleId: Int, chatRoomId: Int, message: String, isImage: Bool) -> AnyPublisher -} - -final class DefaultPostChatDetailUseCase: PostChatDetailUseCase { - - private let chatRepository: ChatRepository - - init(chatRepository: ChatRepository) { - self.chatRepository = chatRepository - } - - func execute(articleId: Int, chatRoomId: Int, message: String, isImage: Bool) -> AnyPublisher { - let request = PostChatDetailRequest(userNickname: UserDataManager.shared.nickname, content: message, isImage: isImage) - return chatRepository.postChatDetail(articleId: articleId, chatRoomId: chatRoomId, request: request) - } -} diff --git a/Koin/Domain/UseCase/Home/FetchNotificationListUseCase.swift b/Koin/Domain/UseCase/Home/FetchNotificationListUseCase.swift index 250edd34..c2dddbdc 100644 --- a/Koin/Domain/UseCase/Home/FetchNotificationListUseCase.swift +++ b/Koin/Domain/UseCase/Home/FetchNotificationListUseCase.swift @@ -9,7 +9,7 @@ import Combine import Foundation protocol FetchNotificationHistoryUseCase { - func execute() async throws -> [NotificationItem] + func execute() async throws -> [NotificationHistoryItem] } final class DefaultFetchNotificationHistoryUseCase: FetchNotificationHistoryUseCase { @@ -20,7 +20,7 @@ final class DefaultFetchNotificationHistoryUseCase: FetchNotificationHistoryUseC self.notificationHistoryRepository = notificationHistoryRepository } - func execute() async throws -> [NotificationItem] { + func execute() async throws -> [NotificationHistoryItem] { try await notificationHistoryRepository.fetchAll() } } diff --git a/Koin/Domain/UseCase/Chat/BlockUserUseCase.swift b/Koin/Domain/UseCase/LostItem/LostItemBlockUserUseCase.swift similarity index 62% rename from Koin/Domain/UseCase/Chat/BlockUserUseCase.swift rename to Koin/Domain/UseCase/LostItem/LostItemBlockUserUseCase.swift index 9eae0303..e5f16862 100644 --- a/Koin/Domain/UseCase/Chat/BlockUserUseCase.swift +++ b/Koin/Domain/UseCase/LostItem/LostItemBlockUserUseCase.swift @@ -1,5 +1,5 @@ // -// BlockUserUseCase.swift +// LostItemBlockUserUseCase.swift // koin // // Created by 김나훈 on 2/18/25. @@ -7,14 +7,14 @@ import Combine -protocol BlockUserUseCase { +protocol LostItemBlockUserUseCase { func execute(articleId: Int, chatRoomId: Int) -> AnyPublisher } -final class DefaultBlockUserUseCase: BlockUserUseCase { +final class DefaultLostItemBlockUserUseCase: LostItemBlockUserUseCase { - private let chatRepository: ChatRepository + private let chatRepository: LostItemRepository - init(chatRepository: ChatRepository) { + init(chatRepository: LostItemRepository) { self.chatRepository = chatRepository } diff --git a/Koin/Domain/UseCase/LostItem/LostItemCreateChatRoomUseCase.swift b/Koin/Domain/UseCase/LostItem/LostItemCreateChatRoomUseCase.swift new file mode 100644 index 00000000..ddb4ab2d --- /dev/null +++ b/Koin/Domain/UseCase/LostItem/LostItemCreateChatRoomUseCase.swift @@ -0,0 +1,24 @@ +// +// LostItemCreateChatRoomUseCase.swift +// koin +// +// Created by 김나훈 on 2/18/25. +// + +import Combine + +protocol LostItemCreateChatRoomUseCase { + func execute(articleId: Int) -> AnyPublisher +} +final class DefaultLostItemCreateChatRoomUseCase: LostItemCreateChatRoomUseCase { + + private let chatRepository: LostItemRepository + + init(chatRepository: LostItemRepository) { + self.chatRepository = chatRepository + } + + func execute(articleId: Int) -> AnyPublisher { + return chatRepository.createChatRoom(articleId: articleId) + } +} diff --git a/Koin/Domain/UseCase/Chat/FetchChatDetailUseCase.swift b/Koin/Domain/UseCase/LostItem/LostItemFetchChatDetailUseCase.swift similarity index 57% rename from Koin/Domain/UseCase/Chat/FetchChatDetailUseCase.swift rename to Koin/Domain/UseCase/LostItem/LostItemFetchChatDetailUseCase.swift index 76732d9f..30dc230f 100644 --- a/Koin/Domain/UseCase/Chat/FetchChatDetailUseCase.swift +++ b/Koin/Domain/UseCase/LostItem/LostItemFetchChatDetailUseCase.swift @@ -1,5 +1,5 @@ // -// FetchChatDetailUseCase.swift +// LostItemFetchChatDetailUseCase.swift // koin // // Created by 김나훈 on 2/18/25. @@ -7,18 +7,18 @@ import Combine -protocol FetchChatDetailUseCase { - func execute(userId: Int, articleId: Int, chatRoomId: Int) -> AnyPublisher<[ChatMessage], ErrorResponse> +protocol LostItemFetchChatDetailUseCase { + func execute(userId: Int, articleId: Int, chatRoomId: Int) -> AnyPublisher<[LostItemChatMessage], ErrorResponse> } -final class DefaultFetchChatDetailUseCase: FetchChatDetailUseCase { +final class DefaultLostItemFetchChatDetailUseCase: LostItemFetchChatDetailUseCase { - private let chatRepository: ChatRepository + private let chatRepository: LostItemRepository - init(chatRepository: ChatRepository) { + init(chatRepository: LostItemRepository) { self.chatRepository = chatRepository } - func execute(userId: Int, articleId: Int, chatRoomId: Int) -> AnyPublisher<[ChatMessage], ErrorResponse> { + func execute(userId: Int, articleId: Int, chatRoomId: Int) -> AnyPublisher<[LostItemChatMessage], ErrorResponse> { return chatRepository.fetchChatDetail(articleId: articleId, chatRoomId: chatRoomId) .map { dtos in dtos.map { $0.toDomain(currentUserId: userId) } diff --git a/Koin/Domain/UseCase/LostItem/LostItemFetchChatRoomUseCase.swift b/Koin/Domain/UseCase/LostItem/LostItemFetchChatRoomUseCase.swift new file mode 100644 index 00000000..77dd1e0a --- /dev/null +++ b/Koin/Domain/UseCase/LostItem/LostItemFetchChatRoomUseCase.swift @@ -0,0 +1,26 @@ +// +// LostItemFetchChatRoomUseCase.swift +// koin +// +// Created by 김나훈 on 2/18/25. +// + +import Combine + +protocol LostItemFetchChatRoomUseCase { + func execute() -> AnyPublisher<[LostItemChatRoomItem], ErrorResponse> +} + +final class DefaultLostItemFetchChatRoomUseCase: LostItemFetchChatRoomUseCase { + private let chatRepository: LostItemRepository + + init(chatRepository: LostItemRepository) { + self.chatRepository = chatRepository + } + + func execute() -> AnyPublisher<[LostItemChatRoomItem], ErrorResponse> { + return chatRepository.fetchChatRoom() + .map { $0.map { $0.toDomain() } } + .eraseToAnyPublisher() + } +} diff --git a/Koin/Domain/UseCase/LostItem/LostItemPostChatDetailUseCase.swift b/Koin/Domain/UseCase/LostItem/LostItemPostChatDetailUseCase.swift new file mode 100644 index 00000000..5264051a --- /dev/null +++ b/Koin/Domain/UseCase/LostItem/LostItemPostChatDetailUseCase.swift @@ -0,0 +1,27 @@ +// +// LostItemPostChatDetailUseCase.swift +// koin +// +// Created by 홍기정 on 1/28/26. +// + +import Foundation +import Combine + +protocol LostItemPostChatDetailUseCase { + func execute(articleId: Int, chatRoomId: Int, message: String, isImage: Bool) -> AnyPublisher +} + +final class DefaultLostItemPostChatDetailUseCase: LostItemPostChatDetailUseCase { + + private let chatRepository: LostItemRepository + + init(chatRepository: LostItemRepository) { + self.chatRepository = chatRepository + } + + func execute(articleId: Int, chatRoomId: Int, message: String, isImage: Bool) -> AnyPublisher { + let request = LostItemPostChatDetailRequest(userNickname: UserDataManager.shared.nickname, content: message, isImage: isImage) + return chatRepository.postChatDetail(articleId: articleId, chatRoomId: chatRoomId, request: request) + } +} diff --git a/Koin/Presentation/Bus/BusSearch/BusSearchViewController.swift b/Koin/Presentation/Bus/BusSearch/BusSearchViewController.swift index f618ad3b..d68d4590 100644 --- a/Koin/Presentation/Bus/BusSearch/BusSearchViewController.swift +++ b/Koin/Presentation/Bus/BusSearch/BusSearchViewController.swift @@ -66,8 +66,6 @@ final class BusSearchViewController: UIViewController { $0.isEnabled = false } - private let busAreaViewController = BusAreaSelectedViewController() - // MARK: - Initialization init(viewModel: BusSearchViewModel) { @@ -92,13 +90,6 @@ final class BusSearchViewController: UIViewController { busNoticeWrappedView.addGestureRecognizer(tapGesture) bind() inputSubject.send(.fetchBusNotice) - - NotificationCenter.default.addObserver( - self, - selector: #selector(self.didDismissDetailNotification(_:)), - name: NSNotification.Name("DismissBusAreaSelectedView"), - object: nil - ) } override func viewWillAppear(_ animated: Bool) { @@ -106,12 +97,6 @@ final class BusSearchViewController: UIViewController { configureNavigationBar(style: .empty) } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - NotificationCenter.default.removeObserver(self, name: NSNotification.Name("DismissBusAreaSelectedView"), object: nil) - } - - // MARK: - Bind private func bind() { @@ -124,35 +109,10 @@ final class BusSearchViewController: UIViewController { self?.updateEmergencyNotice(notice: notice) } }.store(in: &subscriptions) - - busAreaViewController.departureBusAreaPublisher.sink { [weak self] departureArea in - guard let self = self else { return } - changeBusAreaButton(sender: departAreaSelectedButton, title: departureArea) - if departAreaSelectedButton.tag != 0 && arrivedAreaSelectedButton.tag != 0 { - manageSearchButton(isActivated: true) - } - self.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.departureLocationConfirm, .click, departureArea.koreanDescription)) - }.store(in: &subscriptions) - - busAreaViewController.arrivalBusAreaPublisher.sink { [weak self] arrivedArea in - guard let self = self else { return } - changeBusAreaButton(sender: arrivedAreaSelectedButton, title: arrivedArea) - if departAreaSelectedButton.tag != 0 && arrivedAreaSelectedButton.tag != 0 { - manageSearchButton(isActivated: true) - } - self.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.arrivalLocationConfirm, .click, arrivedArea.koreanDescription)) - }.store(in: &subscriptions) } } extension BusSearchViewController { - @objc func didDismissDetailNotification(_ notification: Notification) { - let departure = departAreaSelectedButton.tag != 0 ? BusPlace.allCases[departAreaSelectedButton.tag - 1] : nil - let arrival = arrivedAreaSelectedButton.tag != 0 ? BusPlace.allCases[arrivedAreaSelectedButton.tag - 1] : nil - - busAreaViewController.dismissWithoutConfirmPublisher.send(((departure, arrival), notification.object)) - } - @objc private func tapSearchButton(sender: UIButton) { inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.searchBus, .click, "조회하기")) let repository = DefaultBusRepository(service: DefaultBusService()) @@ -197,7 +157,6 @@ extension BusSearchViewController { let arrival = BusPlace.allCases[arrivedAreaSelectedButton.tag - 1] changeBusAreaButton(sender: departAreaSelectedButton, title: arrival) changeBusAreaButton(sender: arrivedAreaSelectedButton, title: departure) - busAreaViewController.swap(departure: departure, arrival: arrival) } private func updateSelectedBusArea(buttonState: BusAreaButtonState, busPlace: BusPlace?) { @@ -211,12 +170,50 @@ extension BusSearchViewController { } } - busAreaViewController.configure(busAreaLists: busAreaList, buttonState: buttonState) + let departure = selectedBusPlace(from: departAreaSelectedButton) + let arrival = selectedBusPlace(from: arrivedAreaSelectedButton) + let busAreaViewController = BusAreaSelectedViewController( + onDepartureBusAreaSelected: { [weak self] departureArea in + self?.selectDepartureBusArea(departureArea) + }, + onArrivalBusAreaSelected: { [weak self] arrivalArea in + self?.selectArrivalBusArea(arrivalArea) + } + ) + busAreaViewController.configure( + busAreaLists: busAreaList, + buttonState: buttonState, + departure: departure, + arrival: arrival + ) let bottomSheet = BottomSheetViewController(contentViewController: busAreaViewController, defaultHeight: 312.5 + UIApplication.bottomSafeAreaHeight(), cornerRadius: 32, isPannedable: false) bottomSheet.modalPresentationStyle = .overFullScreen bottomSheet.modalTransitionStyle = .crossDissolve present(bottomSheet, animated: true) } + + private func selectedBusPlace(from button: UIButton) -> BusPlace? { + guard button.tag != 0 else { return nil } + return BusPlace.allCases[button.tag - 1] + } + + private func selectDepartureBusArea(_ departureArea: BusPlace) { + changeBusAreaButton(sender: departAreaSelectedButton, title: departureArea) + updateSearchButtonIfNeeded() + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.departureLocationConfirm, .click, departureArea.koreanDescription)) + } + + private func selectArrivalBusArea(_ arrivalArea: BusPlace) { + changeBusAreaButton(sender: arrivedAreaSelectedButton, title: arrivalArea) + updateSearchButtonIfNeeded() + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.arrivalLocationConfirm, .click, arrivalArea.koreanDescription)) + } + + private func updateSearchButtonIfNeeded() { + if departAreaSelectedButton.tag != 0 && arrivedAreaSelectedButton.tag != 0 { + manageSearchButton(isActivated: true) + } + } private func changeBusAreaButton(sender: UIButton, title: BusPlace) { var configuration = UIButton.Configuration.plain() diff --git a/Koin/Presentation/Bus/BusSearch/SubViews/BusAreaSelected/BusAreaSelectdViewController.swift b/Koin/Presentation/Bus/BusSearch/SubViews/BusAreaSelected/BusAreaSelectdViewController.swift index 24b94f45..5fa00c74 100644 --- a/Koin/Presentation/Bus/BusSearch/SubViews/BusAreaSelected/BusAreaSelectdViewController.swift +++ b/Koin/Presentation/Bus/BusSearch/SubViews/BusAreaSelected/BusAreaSelectdViewController.swift @@ -11,12 +11,10 @@ import UIKit final class BusAreaSelectedViewController: UIViewController { //MARK: - Properties - let departureBusAreaPublisher = PassthroughSubject() - let arrivalBusAreaPublisher = PassthroughSubject() - let dismissWithoutConfirmPublisher = PassthroughSubject<((BusPlace?, BusPlace?), Any?), Never>() + private let onDepartureBusAreaSelected: (BusPlace) -> Void + private let onArrivalBusAreaSelected: (BusPlace) -> Void private var buttonState: BusAreaButtonState = .departureSelect private var busRouteType: BusAreaButtonType = .departure - private var subscriptions = Set() //MARK: - UI Components private let busRouteDescriptionlabel = UILabel().then { @@ -41,7 +39,12 @@ final class BusAreaSelectedViewController: UIViewController { } //MARK: - Initialization - init() { + init( + onDepartureBusAreaSelected: @escaping (BusPlace) -> Void, + onArrivalBusAreaSelected: @escaping (BusPlace) -> Void + ) { + self.onDepartureBusAreaSelected = onDepartureBusAreaSelected + self.onArrivalBusAreaSelected = onArrivalBusAreaSelected super.init(nibName: nil, bundle: nil) } @@ -55,34 +58,19 @@ final class BusAreaSelectedViewController: UIViewController { super.viewDidLoad() configureView() confirmButton.addTarget(self, action: #selector(tapConfirmButton), for: .touchUpInside) - - dismissWithoutConfirmPublisher.sink { [weak self] busPlace, currentBusPlace in - let departure = busPlace.0 - let arrival = busPlace.1 - if departure != self?.busAreaCollectionView.departureBusAreaPublisher.value { - self?.busAreaCollectionView.departureBusAreaPublisher.send(departure) - } - - if arrival != self?.busAreaCollectionView.arrivalBusAreaPublisher.value { - self?.busAreaCollectionView.arrivalBusAreaPublisher.send(arrival) - } - - if (departure != nil && self?.busRouteType == .arrival) || (departure == nil && self?.busRouteType == .departure) { - self?.busRouteType = departure != nil ? .departure : .arrival - self?.buttonState = departure != nil ? .departureSelect : .arrivalSelect - } - - }.store(in: &subscriptions) - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - NotificationCenter.default.post(name: NSNotification.Name("DismissBusAreaSelectedView"), object: busRouteType, userInfo: nil) } } extension BusAreaSelectedViewController { - func configure(busAreaLists: [(BusPlace, Bool)], buttonState: BusAreaButtonState) { + func configure( + busAreaLists: [(BusPlace, Bool)], + buttonState: BusAreaButtonState, + departure: BusPlace?, + arrival: BusPlace? + ) { + busAreaCollectionView.departureBusAreaPublisher.send(departure) + busAreaCollectionView.arrivalBusAreaPublisher.send(arrival) + if buttonState == .departureSelect { busRouteType = .departure } @@ -96,11 +84,6 @@ extension BusAreaSelectedViewController { busAreaCollectionView.configure(busAreaLists: busAreaLists, buttonState: busRouteType) } - func swap(departure: BusPlace, arrival: BusPlace) { - busAreaCollectionView.departureBusAreaPublisher.send(arrival) - busAreaCollectionView.arrivalBusAreaPublisher.send(departure) - } - private func setUpView(buttonState: BusAreaButtonState) { let attributeContainer: [NSAttributedString.Key: Any] = [.font: UIFont.appFont(.pretendardMedium, size: 15), .foregroundColor: UIColor.appColor(.neutral0)] let confirmButtonTitle: String @@ -117,11 +100,11 @@ extension BusAreaSelectedViewController { @objc private func tapConfirmButton() { if let departure = busAreaCollectionView.departureBusAreaPublisher.value, busRouteType == .departure { - departureBusAreaPublisher.send(departure) + onDepartureBusAreaSelected(departure) } if let arrival = busAreaCollectionView.arrivalBusAreaPublisher.value, busRouteType == .arrival { - arrivalBusAreaPublisher.send(arrival) + onArrivalBusAreaSelected(arrival) } if buttonState == .allSelected { @@ -174,4 +157,3 @@ extension BusAreaSelectedViewController { self.view.backgroundColor = .systemBackground } } - diff --git a/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewController.swift b/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewController.swift index 15b35c77..a5aa008c 100644 --- a/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewController.swift +++ b/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewController.swift @@ -14,13 +14,12 @@ final class BusSearchResultViewController: UIViewController, UIGestureRecognizer private let viewModel: BusSearchResultViewModel private let inputSubject: PassthroughSubject = .init() private var subscriptions: Set = [] + private var datePickerSubTitle = "" // MARK: - UI Components private let tableView = BusSearchResultTableView(frame: .zero, style: .plain) - private var busSearchDatePickerViewController = BusSearchDatePickerViewController(width: 301, height: 347, paddingBetweenLabels: 10, title: "출발 시각 설정", subTitle: "현재는 정규학기(12월 20일까지)의\n시간표를 제공하고 있어요.", titleColor: .appColor(.neutral700), subTitleColor: .gray) - // MARK: - Initialization init(viewModel: BusSearchResultViewModel) { @@ -69,43 +68,24 @@ final class BusSearchResultViewController: UIViewController, UIGestureRecognizer outputSubject.receive(on: DispatchQueue.main).sink { [weak self] output in switch output { - case let .updateDatePickerData((dates, selectedDate)): - self?.busSearchDatePickerViewController.setPickerItems(items: dates, selectedItems: selectedDate) case let .udpatesSearchedResult(departTime, busSearchedResult): self?.updateSearchedResult(departTime: departTime, departInfo: busSearchedResult) case let .updateSemesterInfo(semesterInfo): self?.updateSemesterInfo(semesterInfo: semesterInfo) } }.store(in: &subscriptions) - - busSearchDatePickerViewController.leftButtonPublisher.sink { [weak self] in - self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.departureNow, .click, "지금 출발")) - }.store(in: &subscriptions) - - busSearchDatePickerViewController.changePickerDate.sink { [weak self] isChanged in - let logValue = isChanged != nil ? "Y" : "N" - self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.departureTimeSettingDone, .click, logValue)) - }.store(in: &subscriptions) - + tableView.tapDepartTimeButtonPublisher .sink { [weak self] in guard let self = self else { return } self.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.searchResultDepartureTime, .click, "출발 시간 설정")) - busSearchDatePickerViewController.modalPresentationStyle = .overFullScreen - present(busSearchDatePickerViewController, animated: true) + self.presentBusSearchDatePickerViewController() }.store(in: &subscriptions) tableView.tapDepartBusTypeButtonPublisher.sink { [weak self] busType in self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.searchResultBusType, .click, busType.koreanDescription)) self?.inputSubject.send(.getSearchedResult(nil, busType)) }.store(in: &subscriptions) - - busSearchDatePickerViewController.pickerSelectedItemsPublisher.sink { [weak self] selectedItem in - if selectedItem.count > 3 { - let time = "\(selectedItem[0]) \(selectedItem[1]) \(selectedItem[2]):\(selectedItem[3])" - self?.inputSubject.send(.getSearchedResult(time, nil)) - } - }.store(in: &subscriptions) } } @@ -143,7 +123,30 @@ extension BusSearchResultViewController { } private func updateSemesterInfo(semesterInfo: SemesterInfo) { - busSearchDatePickerViewController.updateSubMessageLabel(title: "\(semesterInfo.name)(\(semesterInfo.from) ~ \(semesterInfo.to))의\n시간표가 제공됩니다.") + datePickerSubTitle = "현재는 \(semesterInfo.name)(\(semesterInfo.to)까지)의 시간표를 제공하고 있어요." + } + + private func presentBusSearchDatePickerViewController() { + guard let datePickerData = viewModel.datePickerData else { return } + + let busSearchDatePickerViewController = BusSearchDatePickerViewController( + subTitle: datePickerSubTitle, + onPickerDateChanged: { [weak self] isChanged in + let logValue = isChanged != nil ? "Y" : "N" + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.departureTimeSettingDone, .click, logValue)) + }, + onPickerItemsSelected: { [weak self] selectedItems in + guard selectedItems.count > 3 else { return } + self?.inputSubject.send(.updateDatePickerSelectedItems(selectedItems)) + let time = "\(selectedItems[0]) \(selectedItems[1]) \(selectedItems[2]):\(selectedItems[3])" + self?.inputSubject.send(.getSearchedResult(time, nil)) + }, + onDepartureNowTapped: { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.departureNow, .click, "지금 출발")) + } + ) + busSearchDatePickerViewController.setPickerItems(items: datePickerData.0, selectedItems: datePickerData.1) + present(busSearchDatePickerViewController, animated: true) } } diff --git a/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewModel.swift b/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewModel.swift index 271bc17b..a849b35b 100644 --- a/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewModel.swift +++ b/Koin/Presentation/Bus/BusSearchResult/BusSearchResultViewModel.swift @@ -10,12 +10,12 @@ import Combine final class BusSearchResultViewModel: ViewModelProtocol { enum Input { case getDatePickerData + case updateDatePickerSelectedItems([String]) case getSearchedResult(String?, BusType?) case getSemesterInfo case logEvent(EventLabelType, EventParameter.EventCategory, Any) } enum Output { - case updateDatePickerData(([[String]], [String])) case updateSemesterInfo(SemesterInfo) case udpatesSearchedResult(String?, SearchBusInfoResult) } @@ -25,6 +25,7 @@ final class BusSearchResultViewModel: ViewModelProtocol { let busPlaces: (BusPlace, BusPlace) // navigationItem을 설정하기 위해 private 임시 없앰 private var departBusType: BusType = .noValue private var departBusTime: String = "" + private(set) var datePickerData: ([[String]], [String])? private let fetchDatePickerDataUseCase: FetchKoinPickerDataUseCase private let fetchSearchedResultUseCase: SearchBusInfoUseCase private let fetchSemesterInfoUseCase: FetchShuttleBusRoutesUseCase @@ -43,6 +44,8 @@ final class BusSearchResultViewModel: ViewModelProtocol { switch input { case .getDatePickerData: self?.getDatePickerData() + case let .updateDatePickerSelectedItems(selectedItems): + self?.updateDatePickerSelectedItems(selectedItems) case let .getSearchedResult(departDate, busType): self?.getSearchedResult(departDate: departDate, busType: busType) case let .logEvent(label, category, value): @@ -57,8 +60,12 @@ final class BusSearchResultViewModel: ViewModelProtocol { extension BusSearchResultViewModel { private func getDatePickerData() { - let data = fetchDatePickerDataUseCase.execute() - outputSubject.send(.updateDatePickerData(data)) + datePickerData = fetchDatePickerDataUseCase.execute() + } + + private func updateDatePickerSelectedItems(_ selectedItems: [String]) { + guard let datePickerData else { return } + self.datePickerData = (datePickerData.0, selectedItems) } private func getSearchedResult(departDate: String?, busType: BusType?) { diff --git a/Koin/Presentation/Bus/BusSearchResult/SubViews/BusSearchDatePickerViewController.swift b/Koin/Presentation/Bus/BusSearchResult/SubViews/BusSearchDatePickerViewController.swift index 8f90c5c0..28db0c9d 100644 --- a/Koin/Presentation/Bus/BusSearchResult/SubViews/BusSearchDatePickerViewController.swift +++ b/Koin/Presentation/Bus/BusSearchResult/SubViews/BusSearchDatePickerViewController.swift @@ -5,28 +5,74 @@ // Created by JOOMINKYUNG on 11/17/24. // -import Combine import UIKit -final class BusSearchDatePickerViewController: ModalViewController { - +final class BusSearchDatePickerViewController: KoinModalViewController { + + // MARK: - Properties + private let onPickerDateChanged: (Bool?) -> Void + private let onPickerItemsSelected: ([String]) -> Void + private let onDepartureNowTapped: ()->Void + + // MARK: - UI Components + private let customView = UIView() + private let mainTitleLabel = UILabel() + private let subTitleLabel = UILabel() private let pickerView = KoinPickerView() - private var subscriptions: Set = [] - let pickerSelectedItemsPublisher = CurrentValueSubject<[String], Never>([]) - let changePickerDate = PassthroughSubject() - + + // MARK: - Initializer + init( + subTitle: String, + onPickerDateChanged: @escaping (Bool?) -> Void, + onPickerItemsSelected: @escaping ([String]) -> Void, + onDepartureNowTapped: @escaping ()->Void + ) { + self.onPickerDateChanged = onPickerDateChanged + self.onPickerItemsSelected = onPickerItemsSelected + self.onDepartureNowTapped = onDepartureNowTapped + + super.init(configuration: .init( + appearance: .primary, + content: .custom(customView: customView), + button: .buttons( + leftButtonTitle: "지금 출발", + leftButtonStyle: .init( + textColor: .neutral600, + font: .pretendardMedium, + fontSize: 15 + ), + rightButtonTitle: "완료", + rightButtonAction: {} + ), + layout: .init( + contentTopPadding: 24, + contentHorizontalPadding: 0 + ) + )) + + configureSubTitleLabel(subTitle) + } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() - setContentViewInContainer(view: pickerView, frame: .init(x: 0, y: 0, width: 301, height: 122)) - - rightButtonPublisher.sink { [weak self] in - self?.pickerSelectedItemsPublisher.send(self?.pickerView.selectedItemPublisher.value ?? []) - self?.changePickerDate.send(self?.pickerView.changeSelectedItemPublisher.value) - self?.pickerView.changeSelectedItemPublisher.send(nil) - }.store(in: &subscriptions) configureView() - - leftButtonPublisher.sink { [weak self] in + } + + // MARK: - Public + func setPickerItems(items: [[String]], selectedItems: [String]) { + pickerView.changeSelectedItemPublisher.send(nil) + pickerView.setPickerData(items: items, selectedItem: selectedItems) + } + + // MARK: - Override + override func leftButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + let currentDate = Date() let calendar = Calendar.current let hour = calendar.component(.hour, from: currentDate) @@ -35,22 +81,92 @@ final class BusSearchDatePickerViewController: ModalViewController { let adjustedHour = hour % 12 let displayHour = adjustedHour == 0 ? 12 : adjustedHour - let pickerSelectedItems = ["오늘", amPm, String(displayHour), String(format: "%02d", minute)] - self?.pickerView.setSelectedData(selectedItem: pickerSelectedItems) - self?.pickerSelectedItemsPublisher.send(pickerSelectedItems) - self?.pickerView.changeSelectedItemPublisher.send(nil) - }.store(in: &subscriptions) + let selectedItems = ["오늘", amPm, String(displayHour), String(format: "%02d", minute)] + pickerView.setSelectedData(selectedItem: selectedItems) + onPickerItemsSelected(selectedItems) + pickerView.changeSelectedItemPublisher.send(nil) + + onDepartureNowTapped() + } } - func setPickerItems(items: [[String]], selectedItems: [String]) { - pickerView.changeSelectedItemPublisher.send(nil) - pickerView.setPickerData(items: items, selectedItem: selectedItems) + override func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + onPickerItemsSelected(pickerView.selectedItemPublisher.value) + onPickerDateChanged(pickerView.changeSelectedItemPublisher.value) + pickerView.changeSelectedItemPublisher.send(nil) + } + } +} + +extension BusSearchDatePickerViewController { + + private func configureSubTitleLabel(_ text: String) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.do { + $0.alignment = .left + $0.lineSpacing = 14 * 0.6 + $0.lineBreakStrategy = .hangulWordPriority + } + subTitleLabel.attributedText = NSAttributedString( + string: text, + attributes: [ + .foregroundColor: UIColor.appColor(.neutral500), + .font: UIFont.appFont(.pretendardRegular, size: 14), + .paragraphStyle: paragraphStyle, + ] + ) } private func configureView() { - updateMessageLabel(alignment: .left) - updateSubMessageLabel(alignment: .left) - updaterightButton(borderWidth: 0, title: "완료") - updateCloseButton(borderWidth: 0, title: "지금 출발") + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + mainTitleLabel.do { + $0.text = "출발 시각 설정" + $0.textColor = .appColor(.neutral700) + $0.font = .appFont(.pretendardMedium, size: 18) + $0.numberOfLines = 1 + $0.textAlignment = .left + } + subTitleLabel.do { + $0.numberOfLines = 2 + } + pickerView.do { + $0.backgroundColor = .appColor(.neutral50) + } + } + + private func setUpLayouts() { + [mainTitleLabel, subTitleLabel, pickerView].forEach { + customView.addSubview($0) + } + } + + private func setUpConstraints() { + mainTitleLabel.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(24) + $0.height.equalTo(29) + } + subTitleLabel.snp.makeConstraints { + $0.top.equalTo(mainTitleLabel.snp.bottom).offset(8) + $0.leading.trailing.equalTo(mainTitleLabel) + $0.height.equalTo(44) + } + pickerView.snp.makeConstraints { + $0.top.equalTo(subTitleLabel.snp.bottom).offset(24) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() + } + + customView.snp.makeConstraints { + $0.width.equalTo(301) + $0.height.equalTo(227) + } } } diff --git a/Koin/Presentation/CallVan/CallVanChat/CallVanChatViewController.swift b/Koin/Presentation/CallVan/CallVanChat/CallVanChatViewController.swift index 0b5e974e..3dc7395d 100644 --- a/Koin/Presentation/CallVan/CallVanChat/CallVanChatViewController.swift +++ b/Koin/Presentation/CallVan/CallVanChat/CallVanChatViewController.swift @@ -17,7 +17,6 @@ final class CallVanChatViewController: UIViewController { private let inputSubject = PassthroughSubject() private let viewModel: CallVanChatViewModel private var subscriptions: Set = [] - private let textViewPlaceHolder = "메시지 보내기" // MARK: - TitleView private lazy var titleLabel = UILabel() @@ -27,11 +26,7 @@ final class CallVanChatViewController: UIViewController { private lazy var titleView = UIView() // MARK: - UI Components - private let callVanChatTableView = CallVanChatTableView() - private let wrapperView = UIView() - private let sendImageButton = UIButton() - private let messageTextView = UITextView() - private let sendMessageButton = UIButton() + private let chatListView = ChatListView() // MARK: - Initializer init(viewModel: CallVanChatViewModel) { @@ -46,10 +41,7 @@ final class CallVanChatViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() configureView() - setDelegate() - setAddTargets() bind() - setGesture() configureNavigationBar(style: .empty) inputSubject.send(.viewDidLoad) } @@ -71,55 +63,47 @@ final class CallVanChatViewController: UIViewController { case let .showToast(message): showToastMessage(message: message) case let .update(callVanChat): - callVanChatTableView.configure(callVanChat: callVanChat) + chatListView.update(model: ChatListModel(from: callVanChat)) case let .updateData(callVanData): configureNavigationBar(callVanData) } }.store(in: &subscriptions) - - callVanChatTableView.imageTappedPublisher.receive(on: DispatchQueue.main).sink { [weak self] imageUrl in - let zoomedImageViewController = ZoomedImageViewControllerB(shouldShowTitle: false) - zoomedImageViewController.configure(url: imageUrl) - zoomedImageViewController.modalTransitionStyle = .crossDissolve - zoomedImageViewController.modalPresentationStyle = .overFullScreen - self?.present(zoomedImageViewController, animated: true) - }.store(in: &subscriptions) - } -} -extension CallVanChatViewController { - - private func setGesture() { - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapAround)) - tapGesture.cancelsTouchesInView = false - callVanChatTableView.addGestureRecognizer(tapGesture) - } - - private func setAddTargets() { - sendImageButton.addTarget(self, action: #selector(sendImageButtonTapped), for: .touchUpInside) - sendMessageButton.addTarget(self, action: #selector(sendMessageButtonTapped), for: .touchUpInside) - } - - @objc private func didTapAround() { - dismissKeyboard() - } - - @objc private func sendMessageButtonTapped() { - guard messageTextView.textColor == UIColor.appColor(.neutral800) else { - return - } - let text = messageTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { - inputSubject.send(.sendMessage(text)) - inputSubject.send(.logEvent(label: EventParameter.EventLabel.Campus.callvanChatSend, category: .click, value: "")) - messageTextView.text = "" - } + chatListView.messageSendPublisher + .sink { [weak self] message in + self?.inputSubject.send(.sendMessage(message)) + self?.inputSubject.send( + .logEvent( + label: EventParameter.EventLabel.Campus.callvanChatSend, + category: .click, + value: "" + ) + ) + } + .store(in: &subscriptions) + + chatListView.imageSendTappedPublisher + .sink { [weak self] in + self?.presentImagePicker() + } + .store(in: &subscriptions) + + chatListView.imageTappedPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] imageUrl in + let zoomedImageViewController = ZoomedImageViewControllerB(shouldShowTitle: false) + zoomedImageViewController.configure(url: imageUrl) + zoomedImageViewController.modalTransitionStyle = .crossDissolve + zoomedImageViewController.modalPresentationStyle = .overFullScreen + self?.present(zoomedImageViewController, animated: true) + } + .store(in: &subscriptions) } } extension CallVanChatViewController: PHPickerViewControllerDelegate { - @objc private func sendImageButtonTapped() { + private func presentImagePicker() { var configuration = PHPickerConfiguration() configuration.filter = .images configuration.selectionLimit = 1 @@ -153,27 +137,6 @@ extension CallVanChatViewController: PHPickerViewControllerDelegate { } } -extension CallVanChatViewController: UITextViewDelegate { - - private func setDelegate() { - messageTextView.delegate = self - } - - func textViewDidBeginEditing(_ textView: UITextView) { - if textView.textColor == UIColor.appColor(.neutral500) { - textView.text = "" - textView.textColor = UIColor.appColor(.neutral800) - } - } - - func textViewDidEndEditing(_ textView: UITextView) { - if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).count == 0 { - textView.text = textViewPlaceHolder - textView.textColor = UIColor.appColor(.neutral500) - } - } -} - extension CallVanChatViewController { private func configureNavigationBar(_ callVanData: CallVanData) { @@ -213,41 +176,6 @@ extension CallVanChatViewController { private func setUpStyles() { view.backgroundColor = UIColor.appColor(.neutral100) - - // MARK: - UI Components - callVanChatTableView.do { - $0.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) - $0.backgroundColor = .white - $0.separatorStyle = .none - $0.showsVerticalScrollIndicator = false - } - wrapperView.do { - $0.backgroundColor = UIColor.appColor(.neutral100) - } - sendImageButton.do { - $0.setImage(UIImage.appImage(asset: .callVanSendImage), for: .normal) - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - messageTextView.do { - let font = UIFont.appFont(.pretendardRegular, size: 12) - let height: CGFloat = 32 - let topBottomInset = (height - font.lineHeight) / 2 - - $0.isScrollEnabled = false - $0.font = font - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.textContainerInset = UIEdgeInsets(top: topBottomInset, left: 16, bottom: topBottomInset, right: 16) - $0.layer.cornerRadius = 12 - $0.text = textViewPlaceHolder - $0.textColor = UIColor.appColor(.neutral500) - } - sendMessageButton.do { - $0.setImage(UIImage.appImage(asset: .callVanSendMessage), for: .normal) - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } } private func setUpLayouts() { @@ -260,12 +188,7 @@ extension CallVanChatViewController { } // MARK: - UI Components - [sendImageButton, messageTextView, sendMessageButton].forEach { - wrapperView.addSubview($0) - } - [callVanChatTableView, wrapperView].forEach { - view.addSubview($0) - } + view.addSubview(chatListView) } private func setUpConstraints() { @@ -286,30 +209,9 @@ extension CallVanChatViewController { } // MARK: - UI Components - callVanChatTableView.snp.makeConstraints { + chatListView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(wrapperView.snp.top) - } - wrapperView.snp.makeConstraints { - $0.leading.trailing.equalToSuperview() - $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top) - } - sendImageButton.snp.makeConstraints { - $0.size.equalTo(32) - $0.top.equalTo(wrapperView).offset(8) - $0.leading.equalTo(wrapperView).offset(24) - } - sendMessageButton.snp.makeConstraints { - $0.size.equalTo(32) - $0.top.equalTo(wrapperView).offset(8) - $0.trailing.equalTo(wrapperView).offset(-24) - } - messageTextView.snp.makeConstraints { - $0.top.bottom.equalTo(wrapperView).inset(8) - $0.leading.equalTo(sendImageButton.snp.trailing).offset(8) - $0.trailing.equalTo(sendMessageButton.snp.leading).offset(-8) - $0.bottom.greaterThanOrEqualTo(sendImageButton) + $0.leading.trailing.bottom.equalToSuperview() } } } diff --git a/Koin/Presentation/CallVan/CallVanChat/Support/ChatListModel+CallVanChat.swift b/Koin/Presentation/CallVan/CallVanChat/Support/ChatListModel+CallVanChat.swift new file mode 100644 index 00000000..0181c2da --- /dev/null +++ b/Koin/Presentation/CallVan/CallVanChat/Support/ChatListModel+CallVanChat.swift @@ -0,0 +1,31 @@ +// +// ChatListModel+CallVanChat.swift +// koin +// +// Created by 홍기정 on 8/20/26. +// + +extension ChatListModel { + init(from chat: CallVanChat) { + self.init( + dates: chat.dates, + messages: chat.messages.map { messages in + messages.map { ChatMessageRowModel(from: $0) } + } + ) + } +} + +extension ChatMessageRowModel { + init(from message: CallVanChatMessage) { + self.init( + alignment: message.isMine ? .right : .left, + content: message.isImage ? .image(message.content) : .text(message.content), + senderNickname: message.senderNickname, + timeText: message.time, + showsProfile: message.showProfile, + isLeftUser: message.isLeftUser, + profileImage: message.profileImage + ) + } +} diff --git a/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift b/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift index 8fd2cf73..bf8d9fc4 100644 --- a/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift +++ b/Koin/Presentation/CallVan/CallVanList/CallVanListViewController.swift @@ -196,21 +196,36 @@ extension CallVanListViewController { } @objc private func filterButtonTapped() { - let height = min(view.frame.height - view.safeAreaInsets.top - view.safeAreaInsets.bottom, 707) - let contentViewController = CallVanListFilterViewController( - filter: viewModel.filterState, - onApplyButtonTapped: { [weak self] filterState in - guard let self else { return } - inputSubject.send(.updateFilterState(filterState)) - inputSubject.send(.logEvent(label: EventParameter.EventLabel.Campus.callvanFilterApply, category: .click, value: "")) - }, - isLoggedIn: viewModel.isLoggedIn, - height: height + let onFilterItemTapped: (FilterItemModel)->Bool = { [weak self] filterItem in + guard let self else { return false } + if filterItem.title == CallVanMineOrJoined.mine.rawValue + || filterItem.title == CallVanMineOrJoined.joined.rawValue { + if viewModel.isLoggedIn { + return true + } else { + showToastMessage(message: "로그인이 필요한 기능입니다.") + return false + } + } + return true + } + let onApplyTapped: ([FilterGroupModel])->Void = { [weak self] groupModels in + guard let request = CallVanListRequest(from: groupModels) else { + return + } + self?.inputSubject.send(.updateFilterState(request)) + self?.inputSubject.send(.logEvent(label: EventParameter.EventLabel.Campus.callvanFilterApply, category: .click, value: "")) + } + let contentView = FilterBottomSheetView( + groupModels: viewModel.filterState.toFilterGroupModels(), + onFilterItemTapped: onFilterItemTapped, + onApplyTapped: onApplyTapped ) - let bottomSheetViewController = BottomSheetViewController(contentViewController: contentViewController, defaultHeight: height + view.safeAreaInsets.bottom) - bottomSheetViewController.modalTransitionStyle = .crossDissolve - bottomSheetViewController.modalPresentationStyle = .overFullScreen - present(bottomSheetViewController, animated: false) + let bottomSheetVC = BottomSheetViewControllerB(contentView: contentView) + contentView.delegate = bottomSheetVC + + present(bottomSheetVC, animated: false) + inputSubject.send(.logEvent(label: EventParameter.EventLabel.Campus.callvanFilter, category: .click, value: "")) } @@ -390,23 +405,27 @@ extension CallVanListViewController { } private func showRestrictedModal(type: RestrictionType?, until: String?) { - let modalViewController: CallVanModalViewController + let mainTitle: String + let subTitle: String + switch type { case .temporaryRestriction14Days: - guard let until else { - return - } - modalViewController = CallVanModalViewController( - title: RestrictionType.temporaryRestriction14Days.rawValue, - description: RestrictionType.temporaryRestriction14Days.getDescription(until: until)) + mainTitle = RestrictionType.temporaryRestriction14Days.rawValue + subTitle = RestrictionType.temporaryRestriction14Days.getDescription(until: until) case .permanentRestriction: - modalViewController = CallVanModalViewController( - title: RestrictionType.temporaryRestriction14Days.rawValue, - description: RestrictionType.permanentRestriction.getDescription()) - default: + mainTitle = RestrictionType.permanentRestriction.rawValue + subTitle = RestrictionType.permanentRestriction.getDescription() + case nil: return } - modalViewController.modalPresentationStyle = .overFullScreen + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: mainTitle, + subTitleText: subTitle + ), + button: .singleButton(title: "닫기") + )) present(modalViewController, animated: false) } diff --git a/Koin/Presentation/CallVan/CallVanList/Mapper/CallVanListRequest+.swift b/Koin/Presentation/CallVan/CallVanList/Mapper/CallVanListRequest+.swift new file mode 100644 index 00000000..4cf40b38 --- /dev/null +++ b/Koin/Presentation/CallVan/CallVanList/Mapper/CallVanListRequest+.swift @@ -0,0 +1,156 @@ +// +// CallVanListRequest+.swift +// koin +// +// Created by 홍기정 on 8/28/26. +// + +import Foundation + +extension CallVanListRequest { + func toFilterGroupModels() -> [FilterGroupModel] { + var filterGroupModels = [ + FilterGroupModel( + title: "목록", + hasAllButton: true, + items: [ + CallVanMineOrJoined.mine.rawValue, + CallVanMineOrJoined.joined.rawValue + ], + behavior: .single + ), + FilterGroupModel( + title: "정렬", + hasAllButton: false, + items: [ + CallVanListSort.latestDesc.rawValue, + CallVanListSort.departureDesc.rawValue + ], + behavior: .single + ), + FilterGroupModel( + title: "모집 상태", + hasAllButton: true, + items: [ + CallVanRecruitmentState.recruiting.rawValue, + CallVanRecruitmentState.closed.rawValue + ], + behavior: .single + ), + FilterGroupModel( + title: "출발지", + description: "기타 장소는 검색창을 이용해주세요.", + hasAllButton: true, + items: [ + CallVanPlace.frontGate.rawValue, + CallVanPlace.backGate.rawValue, + CallVanPlace.terminal.rawValue, + CallVanPlace.dormitoryMain.rawValue, + CallVanPlace.dormitorySub.rawValue, + CallVanPlace.station.rawValue, + CallVanPlace.asanStation.rawValue + ], + behavior: .multiple + ), + FilterGroupModel( + title: "도착지", + description: "기타 장소는 검색창을 이용해주세요.", + hasAllButton: true, + items: [ + CallVanPlace.frontGate.rawValue, + CallVanPlace.backGate.rawValue, + CallVanPlace.terminal.rawValue, + CallVanPlace.dormitoryMain.rawValue, + CallVanPlace.dormitorySub.rawValue, + CallVanPlace.station.rawValue, + CallVanPlace.asanStation.rawValue + ], + behavior: .multiple + ) + ] + + filterGroupModels[0].didTap(itemAt: mineOrJoined.index) + + if let index = sort.index { + filterGroupModels[1].didTap(itemAt: index) + } + + filterGroupModels[2].didTap(itemAt: state.index) + + departure.forEach { place in + filterGroupModels[3].didTap(itemAt: place.index) + } + + arrival.forEach { place in + filterGroupModels[4].didTap(itemAt: place.index) + } + + return filterGroupModels + } +} + +extension CallVanListRequest { + init?(from filterGroupModels: [FilterGroupModel]) { + guard let mineOrJoined = filterGroupModels[0].selectedItems.first?.title, + let sort = filterGroupModels[1].selectedItems.first?.title, + let state = filterGroupModels[2].selectedItems.first?.title + else { + return nil + } + + switch mineOrJoined { + case CallVanMineOrJoined.mine.rawValue: + self.mineOrJoined = .mine + case CallVanMineOrJoined.joined.rawValue: + self.mineOrJoined = .joined + default: + self.mineOrJoined = .all + } + + switch sort { + case CallVanListSort.departureDesc.rawValue: + self.sort = .departureDesc + default: + self.sort = .latestDesc + } + + switch state { + case CallVanRecruitmentState.recruiting.rawValue: + self.state = .recruiting + case CallVanRecruitmentState.closed.rawValue: + self.state = .closed + default: + self.state = .all + } + + var departure: Set = [] + for selectedItem in filterGroupModels[3].selectedItems { + guard let selectedItem = CallVanPlace(rawValue: selectedItem.title) else { + return + } + if selectedItem == .all { + departure = Set.init(arrayLiteral: .all) + break + } else { + departure.insert(selectedItem) + continue + } + } + self.departure = departure + + var arrival: Set = [] + for selectedItem in filterGroupModels[4].selectedItems { + guard let selectedItem = CallVanPlace(rawValue: selectedItem.title) else { + return + } + if selectedItem == .all { + arrival = Set.init(arrayLiteral: .all) + break + } else { + arrival.insert(selectedItem) + continue + } + } + self.arrival = arrival + } +} diff --git a/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanListFilterViewController.swift b/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanListFilterViewController.swift deleted file mode 100644 index 2037ada4..00000000 --- a/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanListFilterViewController.swift +++ /dev/null @@ -1,523 +0,0 @@ -// -// CallVanListFilterViewController.swift -// koin -// -// Created by 홍기정 on 3/4/26. -// - -import UIKit -import Combine -import Then -import SnapKit - -final class CallVanListFilterViewController: UIViewController { - - // MARK: - Properties - @Published private var filter: CallVanListRequest - private let onApplyButtonTapped: (CallVanListRequest)->Void - private var subscriptions: Set = [] - private let isLoggedIn: Bool - private let height: CGFloat - - // MARK: - UI Components - private let titleLabel = UILabel() - private let closeButton = UIButton() - private let topSeparatorView = UIView() - - private let scrollView = UIScrollView() - private let contentView = UIView() - - private let listLabel = UILabel() - private let listButtonsStackView = UIStackView() - private let listButtons = [ - CallVanFilterButton(filterState: CallVanMineOrJoined.all), - CallVanFilterButton(filterState: CallVanMineOrJoined.mine), - CallVanFilterButton(filterState: CallVanMineOrJoined.joined) - ] - private let listSeparatorView = UIView() - private let sortLabel = UILabel() - private let sortButtonsStackView = UIStackView() - private let sortButtons = [ - CallVanFilterButton(filterState: CallVanListSort.latestDesc), - CallVanFilterButton(filterState: CallVanListSort.departureDesc) - ] - private let sortSeparatorView = UIView() - - private let stateLabel = UILabel() - private let stateButtonsStackView = UIStackView() - private let stateButtons = [ - CallVanFilterButton(filterState: CallVanRecruitmentState.all), - CallVanFilterButton(filterState: CallVanRecruitmentState.recruiting), - CallVanFilterButton(filterState: CallVanRecruitmentState.closed) - ] - private let stateSeparatorView = UIView() - - private let departureLabel = UILabel() - private let departureDescriptionLabel = UILabel() - private let departureButtonsStackView1 = UIStackView() - private let departureButtons1 = [ - CallVanFilterButton(filterState: CallVanPlace.all), - CallVanFilterButton(filterState: CallVanPlace.frontGate), - CallVanFilterButton(filterState: CallVanPlace.backGate), - CallVanFilterButton(filterState: CallVanPlace.terminal) - ] - private let departureButtonsStackView2 = UIStackView() - private let departureButtons2: [CallVanFilterButton] = [ - CallVanFilterButton(filterState: CallVanPlace.dormitoryMain), - CallVanFilterButton(filterState: CallVanPlace.dormitorySub), - CallVanFilterButton(filterState: CallVanPlace.station), - CallVanFilterButton(filterState: CallVanPlace.asanStation) - ] - private let departureSeparatorView = UIView() - - private let arrivalLabel = UILabel() - private let arrivalDescriptionLabel = UILabel() - private let arrivalButtonsStackView1 = UIStackView() - private let arrivalButtons1: [CallVanFilterButton] = [ - CallVanFilterButton(filterState: CallVanPlace.all), - CallVanFilterButton(filterState: CallVanPlace.frontGate), - CallVanFilterButton(filterState: CallVanPlace.backGate), - CallVanFilterButton(filterState: CallVanPlace.terminal) - ] - private let arrivalButtonsStackView2 = UIStackView() - private var arrivalButtons2: [CallVanFilterButton] = [ - CallVanFilterButton(filterState: CallVanPlace.dormitoryMain), - CallVanFilterButton(filterState: CallVanPlace.dormitorySub), - CallVanFilterButton(filterState: CallVanPlace.station), - CallVanFilterButton(filterState: CallVanPlace.asanStation) - ] - private let arrivalSeparatorView = UIView() - - private let resetButton = UIButton() - private let applyButton = UIButton() - private let bottomSeparatorView = UIView() - - // MARK: - Initializer - init( - filter: CallVanListRequest, - onApplyButtonTapped: @escaping (CallVanListRequest)->Void, - isLoggedIn: Bool, - height: CGFloat - ) { - self.filter = filter - self.onApplyButtonTapped = onApplyButtonTapped - self.isLoggedIn = isLoggedIn - self.height = height - super.init(nibName: nil, bundle: nil) - configureView() - bind() - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func bind() { - $filter.sink { [weak self] filter in - guard let self else { return } - - // MARK: - List - listButtons.forEach { button in - button.isSelected = button.filterState as! CallVanMineOrJoined == filter.mineOrJoined - } - - // MARK: - Sort - sortButtons.forEach { button in - button.isSelected = button.filterState as! CallVanListSort == filter.sort - } - - // MARK: - State - stateButtons.forEach { button in - button.isSelected = button.filterState as! CallVanRecruitmentState == filter.state - } - - // MARK: - Departure - if filter.departure == [.all] { - (departureButtons1 + departureButtons2).forEach { button in - button.isSelected = false - } - departureButtons1.first?.isSelected = true - } else { - (departureButtons1 + departureButtons2).forEach { button in - button.isSelected = filter.departure.contains(button.filterState as! CallVanPlace) - } - } - - // MARK: - Arrival - if filter.arrival == [.all] { - (arrivalButtons1 + arrivalButtons2).forEach { button in - button.isSelected = false - } - arrivalButtons1.first?.isSelected = true - } else { - (arrivalButtons1 + arrivalButtons2).forEach { button in - button.isSelected = filter.arrival.contains(button.filterState as! CallVanPlace) - } - } - }.store(in: &subscriptions) - } -} - -extension CallVanListFilterViewController { - - private func configureView() { - view.backgroundColor = .white - setUpLayouts() - setUpStyles() - setUpConstraints() - setAddTargets() - } - - private func setUpStyles() { - // MARK: - Labels - titleLabel.do { - $0.text = "필터" - $0.font = UIFont.appFont(.pretendardSemiBold, size: 18) - $0.textColor = UIColor.appColor(.new500) - } - - [listLabel, sortLabel, stateLabel, departureLabel, arrivalLabel].forEach { - $0.font = UIFont.appFont(.pretendardSemiBold, size: 16) - $0.textColor = UIColor.appColor(.neutral800) - } - listLabel.text = "목록" - sortLabel.text = "정렬" - stateLabel.text = "모집 상태" - departureLabel.text = "출발지" - arrivalLabel.text = "도착지" - - [departureDescriptionLabel, arrivalDescriptionLabel].forEach { - $0.font = UIFont.appFont(.pretendardRegular, size: 12) - $0.textColor = UIColor.appColor(.neutral500) - $0.text = "기타 장소는 검색창을 이용해주세요." - } - - // MARK: - StackView - [listButtonsStackView, sortButtonsStackView, stateButtonsStackView, departureButtonsStackView1, departureButtonsStackView2, arrivalButtonsStackView1, arrivalButtonsStackView2].forEach { - $0.axis = .horizontal - $0.distribution = .fillProportionally - $0.spacing = 12 - } - - // MARK: - Separator View - [topSeparatorView, listSeparatorView, sortSeparatorView, stateSeparatorView, departureSeparatorView, arrivalSeparatorView, bottomSeparatorView].forEach { - $0.backgroundColor = UIColor.appColor(.neutral200) - } - - // MARK: - Buttons - closeButton.do { - $0.setImage(.appImage(asset: .newCancel), for: .normal) - $0.tintColor = UIColor.appColor(.neutral800) - } - resetButton.do { - var configuration = UIButton.Configuration.plain() - configuration.attributedTitle = AttributedString("초기화", attributes: AttributeContainer([ - .font : UIFont.appFont(.pretendardSemiBold, size: 16), - .foregroundColor : UIColor.appColor(.neutral600) - ])) - configuration.image = UIImage.appImage(asset: .refresh) - configuration.imagePadding = 8 - configuration.imagePlacement = .trailing - configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) - $0.configuration = configuration - $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor - $0.layer.borderWidth = 1 - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - } - applyButton.do { - $0.setAttributedTitle(NSAttributedString( - string: "적용하기", - attributes: [ - .font : UIFont.appFont(.pretendardSemiBold, size: 16), - .foregroundColor : UIColor.appColor(.neutral0) - ]), for: .normal) - $0.backgroundColor = UIColor.appColor(.new500) - $0.layer.cornerRadius = 12 - } - } - - private func setUpLayouts() { - listButtons.forEach { - listButtonsStackView.addArrangedSubview($0) - } - sortButtons.forEach { - sortButtonsStackView.addArrangedSubview($0) - } - stateButtons.forEach { - stateButtonsStackView.addArrangedSubview($0) - } - departureButtons1.forEach { - departureButtonsStackView1.addArrangedSubview($0) - } - departureButtons2.forEach { - departureButtonsStackView2.addArrangedSubview($0) - } - arrivalButtons1.forEach { - arrivalButtonsStackView1.addArrangedSubview($0) - } - arrivalButtons2.forEach { - arrivalButtonsStackView2.addArrangedSubview($0) - } - - [listLabel, listButtonsStackView, listSeparatorView, - sortLabel, sortButtonsStackView, sortSeparatorView, - stateLabel, stateButtonsStackView, stateSeparatorView, - departureLabel, departureDescriptionLabel, departureButtonsStackView1, departureButtonsStackView2, departureSeparatorView, - arrivalLabel, arrivalDescriptionLabel, arrivalButtonsStackView1, arrivalButtonsStackView2, arrivalSeparatorView].forEach { - contentView.addSubview($0) - } - [contentView].forEach { - scrollView.addSubview($0) - } - [titleLabel, closeButton, topSeparatorView, - scrollView, - resetButton, applyButton, bottomSeparatorView].forEach { - view.addSubview($0) - } - } - - private func setUpConstraints() { - - // MARK: - 상단 - titleLabel.snp.makeConstraints { - $0.height.equalTo(29) - $0.top.equalToSuperview().offset(12) - $0.leading.equalToSuperview().offset(32) - } - closeButton.snp.makeConstraints { - $0.centerY.equalTo(titleLabel) - $0.trailing.equalToSuperview().offset(-24) - } - topSeparatorView.snp.makeConstraints { - $0.height.equalTo(1) - $0.top.equalTo(titleLabel.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview() - } - - // MARK: - ScrollView - let height = height - 54 - 72 - scrollView.snp.makeConstraints { - $0.top.equalTo(topSeparatorView.snp.bottom) - $0.leading.trailing.equalToSuperview() - $0.height.equalTo(height) - } - contentView.snp.makeConstraints { - $0.edges.equalTo(scrollView) - $0.width.equalTo(scrollView) - } - - // MARK: - List - listLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(12) - } - listButtonsStackView.snp.makeConstraints { - $0.top.equalTo(listLabel.snp.bottom).offset(12) - } - listSeparatorView.snp.makeConstraints { - $0.top.equalTo(listButtonsStackView.snp.bottom).offset(12) - } - - // MARK: - Sort - sortLabel.snp.makeConstraints { - $0.top.equalTo(listSeparatorView.snp.bottom).offset(12) - } - sortButtonsStackView.snp.makeConstraints { - $0.top.equalTo(sortLabel.snp.bottom).offset(12) - } - sortSeparatorView.snp.makeConstraints { - $0.top.equalTo(sortButtonsStackView.snp.bottom).offset(12) - } - - // MARK: - State - stateLabel.snp.makeConstraints { - $0.top.equalTo(sortSeparatorView.snp.bottom).offset(12) - } - stateButtonsStackView.snp.makeConstraints { - $0.top.equalTo(stateLabel.snp.bottom).offset(12) - } - stateSeparatorView.snp.makeConstraints { - $0.top.equalTo(stateButtonsStackView.snp.bottom).offset(12) - } - - // MARK: - Departure - departureLabel.snp.makeConstraints { - $0.top.equalTo(stateSeparatorView.snp.bottom).offset(12) - } - departureDescriptionLabel.snp.makeConstraints { - $0.centerY.equalTo(departureLabel) - $0.leading.equalTo(departureLabel.snp.trailing).offset(8) - } - departureButtonsStackView1.snp.makeConstraints { - $0.top.equalTo(departureLabel.snp.bottom).offset(12) - } - departureButtonsStackView2.snp.makeConstraints { - $0.top.equalTo(departureButtonsStackView1.snp.bottom).offset(8) - } - departureSeparatorView.snp.makeConstraints { - $0.top.equalTo(departureButtonsStackView2.snp.bottom).offset(12) - } - - // MARK: - Arrival - arrivalLabel.snp.makeConstraints { - $0.top.equalTo(departureSeparatorView.snp.bottom).offset(12) - } - arrivalDescriptionLabel.snp.makeConstraints { - $0.centerY.equalTo(arrivalLabel) - $0.leading.equalTo(arrivalLabel.snp.trailing).offset(8) - } - arrivalButtonsStackView1.snp.makeConstraints { - $0.top.equalTo(arrivalLabel.snp.bottom).offset(12) - } - arrivalButtonsStackView2.snp.makeConstraints { - $0.top.equalTo(arrivalButtonsStackView1.snp.bottom).offset(8) - } - arrivalSeparatorView.snp.makeConstraints { - $0.top.equalTo(arrivalButtonsStackView2.snp.bottom).offset(12) - $0.bottom.equalTo(contentView).offset(-12) - } - - // MARK: - 하단 - resetButton.snp.makeConstraints { - $0.height.equalTo(48) - $0.width.equalTo(resetButton.intrinsicContentSize.width) - $0.top.equalTo(scrollView.snp.bottom).offset(12) - $0.leading.equalToSuperview().offset(32) - } - applyButton.snp.makeConstraints { - $0.top.bottom.equalTo(resetButton) - $0.leading.equalTo(resetButton.snp.trailing).offset(12) - $0.trailing.equalToSuperview().offset(-32) - } - bottomSeparatorView.snp.makeConstraints { - $0.height.equalTo(1) - $0.top.equalTo(resetButton.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview() - } - - // MARK: - Common - [listLabel, sortLabel, stateLabel, departureLabel, arrivalLabel].forEach { - $0.snp.makeConstraints { - $0.height.equalTo(26) - $0.leading.equalTo(titleLabel) - } - } - [departureDescriptionLabel, arrivalDescriptionLabel].forEach { - $0.snp.makeConstraints { - $0.height.equalTo(19) - } - } - [listButtonsStackView, sortButtonsStackView, stateButtonsStackView, departureButtonsStackView1, departureButtonsStackView2, arrivalButtonsStackView1, arrivalButtonsStackView2].forEach { - $0.snp.makeConstraints { - $0.height.equalTo(34) - $0.leading.equalTo(titleLabel) - } - } - [listSeparatorView, sortSeparatorView, stateSeparatorView, departureSeparatorView, arrivalSeparatorView].forEach { - $0.snp.makeConstraints { - $0.height.equalTo(1) - $0.leading.trailing.equalToSuperview().inset(32) - } - } - } -} - -extension CallVanListFilterViewController { - - private func setAddTargets() { - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - - listButtons.forEach { - $0.addTarget(self, action: #selector(listButtonTapped(_:)), for: .touchUpInside) - } - sortButtons.forEach { - $0.addTarget(self, action: #selector(sortButtonTapped(_:)), for: .touchUpInside) - } - stateButtons.forEach { - $0.addTarget(self, action: #selector(stateButtonTapped(_:)), for: .touchUpInside) - } - (departureButtons1 + departureButtons2).forEach { - $0.addTarget(self, action: #selector(departureButtonTapped(_:)), for: .touchUpInside) - } - (arrivalButtons1 + arrivalButtons2).forEach { - $0.addTarget(self, action: #selector(arrivalButtonTapped(_:)), for: .touchUpInside) - } - - resetButton.addTarget(self, action: #selector(resetButtonTapped), for: .touchUpInside) - applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside) - } - - @objc private func closeButtonTapped() { - dismissView() - } - - @objc private func listButtonTapped(_ sender: UIButton) { - guard let listButton = sender as? CallVanFilterButton, - let state = listButton.filterState as? CallVanMineOrJoined else { - return - } - if state != .all && isLoggedIn != true { - showToastMessage(message: "로그인이 필요한 기능입니다.") - } else { - filter.mineOrJoined = state - } - } - - @objc private func sortButtonTapped(_ sender: UIButton) { - if let sortButton = sender as? CallVanFilterButton, - let sort = sortButton.filterState as? CallVanListSort { - filter.sort = sort - } - } - - @objc private func stateButtonTapped(_ sender: UIButton) { - if let stateButton = sender as? CallVanFilterButton, - let state = stateButton.filterState as? CallVanRecruitmentState { - filter.state = state - } - } - - // MARK: - Departure - @objc private func departureButtonTapped(_ sender: UIButton) { - guard let departureButton = sender as? CallVanFilterButton, - let departure = departureButton.filterState as? CallVanPlace else { - return - } - if departure == .all { - filter.departure = [.all] - } else { - filter.departure.remove(.all) - if filter.departure == [departure] { - return - } else { - filter.departure.formSymmetricDifference([departure]) - } - } - } - - // MARK: - Arrival - @objc private func arrivalButtonTapped(_ sender: UIButton) { - guard let arrivalButton = sender as? CallVanFilterButton, - let arrival = arrivalButton.filterState as? CallVanPlace else { - return - } - if arrival == .all { - filter.arrival = [.all] - } else { - filter.arrival.remove(.all) - if filter.arrival == [arrival] { - return - } else { - filter.arrival.formSymmetricDifference([arrival]) - } - } - } - - // MARK: - - @objc private func resetButtonTapped() { - self.filter = CallVanListRequest() - } - @objc private func applyButtonTapped() { - onApplyButtonTapped(filter) - dismissView() - } -} diff --git a/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanModalViewController.swift b/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanModalViewController.swift deleted file mode 100644 index 5625e6cb..00000000 --- a/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanModalViewController.swift +++ /dev/null @@ -1,137 +0,0 @@ -// -// CallVanModalViewController.swift -// koin -// -// Created by 홍기정 on 4/6/26. -// - -import UIKit -import SnapKit -import Then - -final class CallVanModalViewController: UIViewController { - - // MARK: - UI Components - private let dimView = UIView() - private let modalView = UIView() - private let titleLabel = UILabel() - private let descriptionLabel = UILabel() - private let closeButton = UIButton() - - // MARK: - Initializer - init(title: String, description: String) { - super.init(nibName: nil, bundle: nil) - titleLabel.attributedText = NSAttributedString( - string: title, - attributes: [ - .font : UIFont.appFont(.pretendardMedium, size: 18), - .foregroundColor : UIColor.appColor(.neutral600) - ] - ) - let paragraphStyle = NSMutableParagraphStyle() - let font = UIFont.appFont(.pretendardRegular, size: 14) - paragraphStyle.lineSpacing = font.lineHeight * 0.6 - descriptionLabel.attributedText = NSAttributedString( - string: description, - attributes: [ - .font : font, - .foregroundColor : UIColor.appColor(.gray), - .paragraphStyle : paragraphStyle - ] - ) - } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life Cycle - override func viewDidLoad() { - super.viewDidLoad() - configureView() - setAddTargets() - } -} - -extension CallVanModalViewController { - - private func setAddTargets() { - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } - - @objc private func closeButtonTapped() { - dismiss(animated: false) - } -} - -extension CallVanModalViewController { - - private func configureView() { - setUpStyles() - setUpLayouts() - setUpConstraints() - } - - private func setUpStyles() { - dimView.do { - $0.alpha = 0.7 - $0.backgroundColor = UIColor.black - } - modalView.do { - $0.backgroundColor = UIColor.white - $0.layer.cornerRadius = 8 - } - descriptionLabel.do { - $0.numberOfLines = 2 - $0.textAlignment = .center - } - closeButton.do { - $0.setAttributedTitle( - NSAttributedString( - string: "닫기", - attributes: [ - .font : UIFont.appFont(.pretendardMedium, size: 15), - .foregroundColor : UIColor.white - ]), - for: .normal - ) - $0.backgroundColor = UIColor.appColor(.new500) - $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - } - } - - private func setUpLayouts() { - [titleLabel, descriptionLabel, closeButton].forEach { - modalView.addSubview($0) - } - [dimView, modalView].forEach { - view.addSubview($0) - } - } - - private func setUpConstraints() { - dimView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - modalView.snp.makeConstraints { - $0.center.equalToSuperview() - $0.width.equalTo(300) - } - titleLabel.snp.makeConstraints { - $0.height.equalTo(29) - $0.centerX.equalToSuperview() - $0.top.equalToSuperview().offset(24) - } - descriptionLabel.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.top.equalTo(titleLabel.snp.bottom).offset(8) - } - closeButton.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.top.equalTo(descriptionLabel.snp.bottom).offset(24) - $0.height.equalTo(48) - $0.bottom.equalToSuperview().offset(-24) - $0.leading.trailing.equalToSuperview().inset(32) - } - } -} diff --git a/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift b/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift index df74f8b3..d4d97e86 100644 --- a/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift +++ b/Koin/Presentation/CallVan/CallVanPost/CallVanPostViewController.swift @@ -23,6 +23,8 @@ final class CallVanPostViewController: UIViewController { private var subscriptions: Set = [] // MARK: - UI Components + private let scrollView = UIScrollView() + private let scrollContentView = UIView() private let placeView = CallVanPostPlaceView() private let dateView = CallVanPostDateView() private let timeView = CallVanPostTimeView() @@ -33,11 +35,18 @@ final class CallVanPostViewController: UIViewController { private let postButton = UIButton() private let bottomSheetContentView = CallVanPostPlaceBottomSheetView() - private lazy var bottomSheetViewController = BottomSheetViewControllerB( - contentView: bottomSheetContentView, - dimColor: .black, - dimAlpha: 0.7, - backgroundColor: UIColor.appColor(.neutral0) + + // MARK: - Dropdown + private lazy var dropdownHost = KoinDropdownHost(scrollView: scrollView) + private lazy var dateDropdown = dropdownHost.makeDropdown( + trigger: dateView.dropdownTrigger, + contentView: dateView.dropdownContentView, + configuration: .init(topPadding: 12, shadow: .shadow2) + ) + private lazy var timeDropdown = dropdownHost.makeDropdown( + trigger: timeView.dropdownTrigger, + contentView: timeView.dropdownContentView, + configuration: .init(topPadding: 12, shadow: .shadow2) ) // MARK: - Initializer @@ -56,7 +65,6 @@ final class CallVanPostViewController: UIViewController { configureNavigationBar(style: .empty) configureView() setAddTargets() - setDelegates() bind() dateView.update(Date()) timeView.update(Date()) @@ -104,7 +112,7 @@ final class CallVanPostViewController: UIViewController { }.store(in: &subscriptions) dateView.dateButtonTappedPublisher.receive(on: DispatchQueue.main).sink { [weak self] in - self?.timeView.dismissTimeDropDownView() + self?.dateDropdown.toggle() }.store(in: &subscriptions) dateView.dateChangedPublisher.sink { [weak self] date in @@ -112,7 +120,7 @@ final class CallVanPostViewController: UIViewController { }.store(in: &subscriptions) timeView.timeButtonTappedPublisher.receive(on: DispatchQueue.main).sink { [weak self] in - self?.dateView.dismissDateDropDownView() + self?.timeDropdown.toggle() }.store(in: &subscriptions) timeView.timeChangedPublisher.sink { [weak self] time in @@ -139,16 +147,13 @@ extension CallVanPostViewController: PopLoggable { } extension CallVanPostViewController { - - private func setDelegates() { - bottomSheetContentView.delegate = bottomSheetViewController - } - private func setAddTargets() { postButton.addTarget(self, action: #selector(postButtonTapped), for: .touchUpInside) } @objc private func postButtonTapped() { + guard !dropdownHost.isPresenting else { return } + postButton.isUserInteractionEnabled = false inputSubject.send(.logEvent(label: EventParameter.EventLabel.Campus.callvanWriteDone, category: .click, value: "")) inputSubject.send(.postData) @@ -158,25 +163,32 @@ extension CallVanPostViewController { extension CallVanPostViewController { private func showRestrictedModal(_ type: RestrictionType?, _ until: String?) { - let modalViewController: CallVanModalViewController + let mainTitle: String + let subTitle: String + switch type { case .temporaryRestriction14Days: - guard let until else { - return - } - modalViewController = CallVanModalViewController( - title: RestrictionType.temporaryRestriction14Days.rawValue, - description: RestrictionType.temporaryRestriction14Days.getDescription(until: until)) + mainTitle = RestrictionType.temporaryRestriction14Days.rawValue + subTitle = RestrictionType.temporaryRestriction14Days.getDescription(until: until) case .permanentRestriction: - modalViewController = CallVanModalViewController( - title: RestrictionType.temporaryRestriction14Days.rawValue, - description: RestrictionType.permanentRestriction.getDescription()) - default: + mainTitle = RestrictionType.permanentRestriction.rawValue + subTitle = RestrictionType.permanentRestriction.getDescription() + case nil: return } - modalViewController.modalPresentationStyle = .overFullScreen - present(modalViewController, animated: false) - } + + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: mainTitle, + subTitleText: subTitle + ), + button: .singleButton( + title: "닫기" + ) + )) + present(modalViewController, animated: true) + } private func presentDeparturePlaceBottomSheet() { let onApplyButtonTapped: (CallVanPlace, String?)->Void = { [weak self] (place, customPlace) in @@ -189,12 +201,13 @@ extension CallVanPostViewController { } bottomSheetContentView.configure( title: .departure, - place: viewModel.request.departureType, + selectedPlace: viewModel.request.departureType, customPlace: viewModel.request.departureCustomName, onApplyButtonTapped: onApplyButtonTapped ) - present(bottomSheetViewController, animated: false) + presentPlaceBottomSheet() } + private func presentArrivalPlaceBottomSheet() { let onApplyButtonTapped: (CallVanPlace, String?)->Void = { [weak self] (place, customPlace) in guard let self else { return } @@ -206,10 +219,16 @@ extension CallVanPostViewController { } bottomSheetContentView.configure( title: .arrival, - place: viewModel.request.arrivalType, + selectedPlace: viewModel.request.arrivalType, customPlace: viewModel.request.arrivalCustomName, onApplyButtonTapped: onApplyButtonTapped ) + presentPlaceBottomSheet() + } + + private func presentPlaceBottomSheet() { + let bottomSheetViewController = BottomSheetViewControllerB(contentView: bottomSheetContentView) + bottomSheetContentView.delegate = bottomSheetViewController present(bottomSheetViewController, animated: false) } } @@ -292,15 +311,26 @@ extension CallVanPostViewController { } } private func setUpLayouts() { - [placeView, participantsView, separatorView, descriptionLabel, postButton, - timeView, dateView].forEach { + [placeView, dateView, timeView, participantsView].forEach { + scrollContentView.addSubview($0) + } + scrollView.addSubview(scrollContentView) + [scrollView, separatorView, descriptionLabel, postButton].forEach { view.addSubview($0) } } private func setUpConstraints() { - placeView.snp.makeConstraints { + scrollView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(separatorView.snp.top) + } + scrollContentView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalToSuperview() + } + placeView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() } dateView.snp.makeConstraints { $0.top.equalTo(placeView.snp.bottom) @@ -313,6 +343,7 @@ extension CallVanPostViewController { participantsView.snp.makeConstraints { $0.top.equalTo(timeView.snp.bottom) $0.leading.trailing.equalToSuperview() + $0.bottom.equalToSuperview() } postButton.snp.makeConstraints { diff --git a/Koin/Presentation/CallVan/CallVanList/Subviews/CallVanFilterButton.swift b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanFilterButton.swift similarity index 100% rename from Koin/Presentation/CallVan/CallVanList/Subviews/CallVanFilterButton.swift rename to Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanFilterButton.swift diff --git a/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostDateView.swift b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostDateView.swift index 9104a621..14d2851b 100644 --- a/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostDateView.swift +++ b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostDateView.swift @@ -10,7 +10,7 @@ import Combine import SnapKit import Then -final class CallVanPostDateView: ExtendedTouchAreaView { +final class CallVanPostDateView: UIView { // MARK: - Properties let dateButtonTappedPublisher = PassthroughSubject() @@ -21,6 +21,10 @@ final class CallVanPostDateView: ExtendedTouchAreaView { $0.dateFormat = "yyyy년 M월 d일" } + // MARK: - Dropdown + var dropdownTrigger: UIView { dateButton } + var dropdownContentView: UIView & KoinDropdownContentView { dateDropDownView } + // MARK: - UI Components private let titleLabel = UILabel() private let descriptionLabel = UILabel() @@ -58,9 +62,6 @@ extension CallVanPostDateView { }.store(in: &subscriptions) - dateDropDownView.applyButtonTappedPublisher.sink { [weak self] in - self?.dismissDateDropDownView() - }.store(in: &subscriptions) } } @@ -72,32 +73,6 @@ extension CallVanPostDateView { @objc private func dateButtonTapped() { dateButtonTappedPublisher.send() - - if dateDropDownView.isHidden { - presentDateDropDownView() - } else { - dismissDateDropDownView() - } - } - - private func presentDateDropDownView() { - dateDropDownView.isHidden = false - UIView.animate(springDuration: 0.3, bounce: 0.3, initialSpringVelocity: 0) { [weak self] in - guard let self else { return } - dateDropDownView.alpha = 1 - dateDropDownView.transform = CGAffineTransform.identity - } - } - - func dismissDateDropDownView() { - UIView.animate(springDuration: 0.2, bounce: 0, initialSpringVelocity: 0) { [weak self] in - guard let self else { return } - dateDropDownView.alpha = 0 - dateDropDownView.transform = CGAffineTransform(translationX: 0, y: -20) - } - DispatchQueue.main.asyncAfter(deadline: .now()+0.1 ) { [weak self] in - self?.dateDropDownView.isHidden = true - } } } @@ -135,16 +110,11 @@ extension CallVanPostDateView { dateDropDownView.do { $0.backgroundColor = UIColor.appColor(.neutral100) $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - $0.layer.applySketchShadow(color: UIColor.appColor(.neutral800), alpha: 0.08, x: 0, y: 4, blur: 10, spread: 0) - $0.isHidden = true - $0.transform = CGAffineTransform(translationX: 0, y: -20) - $0.alpha = 0 } } private func setUpLayouts() { - [titleLabel, descriptionLabel, dateButton, dateLabel, downArrowImageView, dateDropDownView].forEach { + [titleLabel, descriptionLabel, dateButton, dateLabel, downArrowImageView].forEach { addSubview($0) } } @@ -174,10 +144,5 @@ extension CallVanPostDateView { $0.centerY.equalTo(dateButton) $0.trailing.equalTo(dateButton).offset(-12) } - dateDropDownView.snp.makeConstraints { - $0.height.equalTo(153) - $0.top.equalTo(dateButton.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview().inset(24) - } } } diff --git a/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostPlaceBottomSheetView.swift b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostPlaceBottomSheetView.swift index 01272067..eaa06547 100644 --- a/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostPlaceBottomSheetView.swift +++ b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostPlaceBottomSheetView.swift @@ -17,35 +17,54 @@ final class CallVanPostPlaceBottomSheetView: UIView { case arrival = "도착지가 어디인가요?" } + // MARK: - State + private var selectedPlace: CallVanPlace? { + didSet { + updateSelection(selectedPlace) + updateTextField(isEditing: selectedPlace == .custom) + validate() + } + } + private var customPlace: String? { + didSet { + validate() + } + } + // MARK: - Properties weak var delegate: BottomSheetViewControllerBDelegate? private var onApplyButtonTapped: ((CallVanPlace, String?)->Void)? + private var filterGroup = FilterGroupModel( + title: "", + hasAllButton: false, + items: [ + CallVanPlace.frontGate.rawValue, + CallVanPlace.backGate.rawValue, + CallVanPlace.dormitoryMain.rawValue, + CallVanPlace.dormitorySub.rawValue, + CallVanPlace.terminal.rawValue, + CallVanPlace.station.rawValue, + CallVanPlace.asanStation.rawValue, + CallVanPlace.custom.rawValue + ], + behavior: .single, + allowEmptySelection: true + ) + private var subscriptions: Set = [] // MARK: - UI Components - private let containerView = UIView() - private let titleLabel = UILabel() private let closeButton = UIButton() private let topSeparatorView = UIView() - private let buttonsStackView1 = UIStackView() - private let buttons1 = [ - CallVanFilterButton(filterState: CallVanPlace.frontGate), - CallVanFilterButton(filterState: CallVanPlace.backGate), - CallVanFilterButton(filterState: CallVanPlace.dormitoryMain), - CallVanFilterButton(filterState: CallVanPlace.dormitorySub) - ] - - private let buttonsStackView2 = UIStackView() - private let buttons2 = [ - CallVanFilterButton(filterState: CallVanPlace.terminal), - CallVanFilterButton(filterState: CallVanPlace.station), - CallVanFilterButton(filterState: CallVanPlace.asanStation) - ] - private let customButton = CallVanFilterButton(filterState: CallVanPlace.custom) + private lazy var filterGroupCollectionView = FilterGroupCollectionView(filterGroup: filterGroup) private let separatorView = UIView() - private let customPlaceTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral800), font: UIFont.appFont(.pretendardMedium, size: 15)) + private let customPlaceTextField = DefaultTextField( + placeholder: "", + placeholderColor: UIColor.appColor(.neutral800), + font: UIFont.appFont(.pretendardMedium, size: 15) + ) private let applyButton = UIButton() private let bottomSeparatorView = UIView() @@ -55,177 +74,121 @@ final class CallVanPostPlaceBottomSheetView: UIView { configureView() setAddTargets() setDelegate() + bind() } // MARK: - Public func configure( title: Title, - place: CallVanPlace?, + selectedPlace: CallVanPlace?, customPlace: String?, onApplyButtonTapped: @escaping (CallVanPlace, String?)->Void ) { + self.selectedPlace = selectedPlace + self.customPlace = customPlace self.onApplyButtonTapped = onApplyButtonTapped - titleLabel.text = title.rawValue - - resetState() - - if place == .custom { - (buttons1 + buttons2).forEach { - $0.isSelected = false - } - customButton.isSelected = true - customPlaceTextField.text = customPlace - updateCustomPlaceTextField(isVisible: true) - updateApplyButtonTitle(isCustomSelected: true) - valiate(customPlaceTextField) - } else { - let place = place ?? CallVanPlace.frontGate - (buttons1 + buttons2).forEach { - $0.isSelected = $0.filterState as? CallVanPlace == place - } - customButton.isSelected = false - updateCustomPlaceTextField(isVisible: false) - updateApplyButtonTitle(isCustomSelected: false) - applyButton.backgroundColor = UIColor.appColor(.new500) - applyButton.isEnabled = true - } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - Bind + private func bind() { + filterGroupCollectionView.itemTappedPublisher + .sink { [weak self] selectedIndex in + guard let self, + let selectedPlace = CallVanPlace(rawValue: filterGroup.items[selectedIndex].title) else { + return + } + self.selectedPlace = selectedPlace + } + .store(in: &subscriptions) + } } extension CallVanPostPlaceBottomSheetView { + // MARK: - Set Delegate + private func setDelegate() { + customPlaceTextField.delegate = self + } + // MARK: - Set AddTargets private func setAddTargets() { closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - - (buttons1 + buttons2).forEach { - $0.addTarget(self, action: #selector(placeButtonTapped(_:)), for: .touchUpInside) - } - customButton.addTarget(self, action: #selector(customButtonTapped(_:)), for: .touchUpInside) - + customPlaceTextField.addTarget(self, action: #selector(editingChanged), for: .editingChanged) applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside) - - customPlaceTextField.addTarget(self, action: #selector(valiate(_:)), for: .editingChanged) } + // MARK: - Objc @objc private func closeButtonTapped() { customPlaceTextField.resignFirstResponder() delegate?.dismiss() } - @objc private func placeButtonTapped(_ sender: UIButton) { - if let placeButton = sender as? CallVanFilterButton { - (buttons1 + buttons2).forEach { - $0.isSelected = $0.filterState.rawValue == placeButton.filterState.rawValue - } - } - customButton.isSelected = false - - updateCustomPlaceTextField(isVisible: false) - UIView.animate( - withDuration: 0.2, - animations: { [weak self] in - self?.superview?.layoutIfNeeded() - self?.layoutIfNeeded() - self?.applyButton.isEnabled = true - self?.applyButton.backgroundColor = UIColor.appColor(.new500) - self?.updateApplyButtonTitle(isCustomSelected: false) - }, - completion: { [weak self] _ in - self?.customPlaceTextField.resignFirstResponder() - } - ) - } - - @objc private func customButtonTapped(_ sender: UIButton) { - (buttons1 + buttons2).forEach { - $0.isSelected = false - } - sender.isSelected = true - - updateCustomPlaceTextField(isVisible: true) - UIView.animate( - withDuration: 0.2, - animations: { [weak self] in - self?.superview?.layoutIfNeeded() - self?.layoutIfNeeded() - self?.updateApplyButtonTitle(isCustomSelected: true) - }, - completion: { [weak self] _ in - self?.customPlaceTextField.becomeFirstResponder() - } - ) - } - @objc private func applyButtonTapped() { - if let selectedButton = (buttons1 + buttons2).first(where: { $0.isSelected }), - let selectedPlace = selectedButton.filterState as? CallVanPlace { - onApplyButtonTapped?(selectedPlace, nil) - } - else if customButton.isSelected, - let customPlace = customPlaceTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines), - !customPlace.isEmpty { + guard let selectedPlace else { return } + customPlaceTextField.resignFirstResponder() + + switch selectedPlace { + case .custom: onApplyButtonTapped?(.custom, customPlace) + default: + onApplyButtonTapped?(selectedPlace, nil) } - customPlaceTextField.resignFirstResponder() + delegate?.dismiss() } + + @objc private func editingChanged() { + customPlace = customPlaceTextField.text?.trimmingCharacters(in: .whitespacesAndNewlines) + } } extension CallVanPostPlaceBottomSheetView: UITextFieldDelegate { - - private func setDelegate() { - customPlaceTextField.delegate = self - } - + // MARK: - Handle Textfield func textFieldShouldReturn(_ textField: UITextField) -> Bool { + customPlace = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) self.endEditing(true) return true } func textFieldDidBeginEditing(_ textField: UITextField) { - valiate(textField) + customPlace = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) } - @objc private func valiate(_ textField: UITextField) { - if let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines), - !text.isEmpty { - applyButton.backgroundColor = UIColor.appColor(.new500) - applyButton.isEnabled = true - } else { - applyButton.backgroundColor = UIColor.appColor(.neutral400) - applyButton.isEnabled = false - } + private func updateTextField(isEditing: Bool) { + updateTextField(isVisible: isEditing) + updateApplyButton(isCustomSelected: isEditing) } -} - -extension CallVanPostPlaceBottomSheetView { - - private func resetState() { - (buttons1 + buttons2).forEach { - $0.isSelected = false - } - customButton.isSelected = false - customPlaceTextField.text = nil - customPlaceTextField.resignFirstResponder() - updateCustomPlaceTextField(isVisible: false) - updateApplyButtonTitle(isCustomSelected: false) - applyButton.backgroundColor = UIColor.appColor(.new500) - applyButton.isEnabled = true - } - - private func updateCustomPlaceTextField(isVisible: Bool) { + + private func updateTextField(isVisible: Bool) { customPlaceTextField.snp.remakeConstraints { $0.height.equalTo(isVisible ? 47 : 0) $0.top.equalTo(separatorView.snp.bottom).offset(isVisible ? 24 : 0) $0.leading.trailing.equalToSuperview().inset(32) } + + if isVisible { + customPlaceTextField.isHidden = false + } + + UIView.animate(springDuration: 0.2) { [weak self] in + self?.superview?.layoutIfNeeded() + self?.layoutIfNeeded() + self?.customPlaceTextField.alpha = isVisible ? 1 : 0 + } completion: { [weak self] _ in + self?.customPlaceTextField.isHidden = !isVisible + } + + if isVisible { + customPlaceTextField.becomeFirstResponder() + } else { + customPlaceTextField.resignFirstResponder() + } } - - private func updateApplyButtonTitle(isCustomSelected: Bool) { + + private func updateApplyButton(isCustomSelected: Bool) { let title = isCustomSelected ? "입력완료" : "선택하기" applyButton.setAttributedTitle(NSAttributedString( string: title, @@ -235,7 +198,54 @@ extension CallVanPostPlaceBottomSheetView { ]), for: .normal ) } +} + +extension CallVanPostPlaceBottomSheetView { + // MARK: - Update CollecitonView + private func updateSelection(_ selectedPlace: CallVanPlace?) { + let before = filterGroup.items.map(\.isSelected) + + filterGroup.reset(true) + if let selectedPlace, + let selectedIndex = filterGroup.items.firstIndex(where: { $0.title == selectedPlace.rawValue }) { + filterGroup.didTap(itemAt: selectedIndex) + } + + let after = filterGroup.items.map(\.isSelected) + + filterGroupCollectionView.update( + filterGroup: filterGroup, + changed: Self.changedIndexPaths(before: before, after: after) + ) + } + private static func changedIndexPaths(before: [Bool], after: [Bool]) -> [IndexPath] { + zip(before, after).enumerated().compactMap { index, pair in + pair.0 != pair.1 ? IndexPath(row: index, section: 0) : nil + } + } +} + +extension CallVanPostPlaceBottomSheetView { + // MARK: - Validate + private func validate() { + applyButton.backgroundColor = isValid ? UIColor.appColor(.new500) : UIColor.appColor(.neutral400) + applyButton.isEnabled = isValid + } + + private var isValid: Bool { + guard let selectedPlace else { return false } + switch selectedPlace { + case .custom: + let isEmpty = customPlace?.isEmpty ?? true + return !isEmpty + default: + return true + } + } +} + +extension CallVanPostPlaceBottomSheetView { private func configureView() { setUpStyles() setUpLayouts() @@ -243,7 +253,7 @@ extension CallVanPostPlaceBottomSheetView { } private func setUpStyles() { - containerView.do { + self.do { $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] $0.layer.cornerRadius = 32 $0.backgroundColor = UIColor.appColor(.neutral0) @@ -260,11 +270,6 @@ extension CallVanPostPlaceBottomSheetView { [topSeparatorView, separatorView, bottomSeparatorView].forEach { $0.backgroundColor = UIColor.appColor(.neutral300) } - [buttonsStackView1, buttonsStackView2].forEach { - $0.axis = .horizontal - $0.spacing = 12 - $0.distribution = .fillProportionally - } customPlaceTextField.do { $0.layer.cornerRadius = 12 $0.layer.borderColor = UIColor.ColorSystem.Neutral.gray400.cgColor @@ -289,33 +294,18 @@ extension CallVanPostPlaceBottomSheetView { } private func setUpLayouts() { - buttons1.forEach { - buttonsStackView1.addArrangedSubview($0) - } - buttons2.forEach { - buttonsStackView2.addArrangedSubview($0) - } - buttonsStackView2.addArrangedSubview(customButton) - [titleLabel, closeButton, topSeparatorView, - buttonsStackView1, buttonsStackView2, + filterGroupCollectionView, separatorView, customPlaceTextField, applyButton, bottomSeparatorView].forEach { - containerView.addSubview($0) - } - - [containerView].forEach { addSubview($0) } } private func setUpConstraints() { - containerView.snp.makeConstraints { - $0.edges.equalToSuperview() - } titleLabel.snp.makeConstraints { $0.height.equalTo(29) - $0.top.equalTo(containerView).offset(12) + $0.top.equalToSuperview().offset(12) $0.leading.equalToSuperview().offset(32) } closeButton.snp.makeConstraints { @@ -327,21 +317,13 @@ extension CallVanPostPlaceBottomSheetView { $0.leading.trailing.equalToSuperview() $0.top.equalTo(titleLabel.snp.bottom).offset(12) } - - buttonsStackView1.snp.makeConstraints { - $0.height.equalTo(34) + filterGroupCollectionView.snp.makeConstraints { $0.top.equalTo(topSeparatorView.snp.bottom).offset(12) - $0.leading.equalToSuperview().offset(32) - } - buttonsStackView2.snp.makeConstraints { - $0.height.equalTo(34) - $0.top.equalTo(buttonsStackView1.snp.bottom).offset(8) - $0.leading.equalTo(buttonsStackView1) + $0.leading.trailing.equalToSuperview().inset(32) } - separatorView.snp.makeConstraints { $0.height.equalTo(1) - $0.top.equalTo(buttonsStackView2.snp.bottom).offset(12) + $0.top.equalTo(filterGroupCollectionView.snp.bottom).offset(12) $0.leading.trailing.equalToSuperview().inset(32) } customPlaceTextField.snp.makeConstraints { diff --git a/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostTimeView.swift b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostTimeView.swift index 112b69a8..07e993b7 100644 --- a/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostTimeView.swift +++ b/Koin/Presentation/CallVan/CallVanPost/Subviews/CallVanPostTimeView.swift @@ -10,16 +10,21 @@ import Combine import SnapKit import Then -final class CallVanPostTimeView: ExtendedTouchAreaView { +final class CallVanPostTimeView: UIView { // MARK: - Properteis let timeButtonTappedPublisher = PassthroughSubject() let timeChangedPublisher = PassthroughSubject() + private var subscriptions: Set = [] private let formatter = DateFormatter().then { $0.locale = Locale(identifier: "ko_KR") } + // MARK: - Dropdown + var dropdownTrigger: UIView { timeButton } + var dropdownContentView: UIView & KoinDropdownContentView { timeDropDownView } + // MARK: - UI Components private let titleLabel = UILabel() private let descriptionLabel = UILabel() @@ -40,6 +45,7 @@ final class CallVanPostTimeView: ExtendedTouchAreaView { fatalError("init(coder:) has not been implemented") } + // MARK: - Public func update(_ date: Date) { timeDropDownView.reset(initialDate: date) @@ -66,9 +72,6 @@ extension CallVanPostTimeView { } }.store(in: &subscriptions) - timeDropDownView.applyButtonTappedPublisher.sink { [weak self] in - self?.dismissTimeDropDownView() - }.store(in: &subscriptions) } } @@ -80,32 +83,6 @@ extension CallVanPostTimeView { @objc private func timeButtonTapped() { timeButtonTappedPublisher.send() - - if timeDropDownView.isHidden { - presentTimeDropDownView() - } else { - dismissTimeDropDownView() - } - } - - private func presentTimeDropDownView() { - timeDropDownView.isHidden = false - UIView.animate(springDuration: 0.3, bounce: 0.3, initialSpringVelocity: 0) { [weak self] in - guard let self else { return } - timeDropDownView.alpha = 1 - timeDropDownView.transform = CGAffineTransform.identity - } - } - - func dismissTimeDropDownView() { - UIView.animate(springDuration: 0.2, bounce: 0, initialSpringVelocity: 0) { [weak self] in - guard let self else { return } - timeDropDownView.alpha = 0 - timeDropDownView.transform = CGAffineTransform(translationX: 0, y: -20) - } - DispatchQueue.main.asyncAfter(deadline: .now()+0.1 ) { [weak self] in - self?.timeDropDownView.isHidden = true - } } } @@ -121,11 +98,6 @@ extension CallVanPostTimeView { timeDropDownView.do { $0.backgroundColor = UIColor.appColor(.neutral100) $0.layer.cornerRadius = 8 - $0.clipsToBounds = true - $0.layer.applySketchShadow(color: UIColor.appColor(.neutral800), alpha: 0.08, x: 0, y: 4, blur: 10, spread: 0) - $0.isHidden = true - $0.transform = CGAffineTransform(translationX: 0, y: -20) - $0.alpha = 0 } titleLabel.do { $0.text = "출발 시각" @@ -159,7 +131,7 @@ extension CallVanPostTimeView { } private func setUpLayouts() { - [titleLabel, descriptionLabel, timeButton, amPmLabel, separatorView, timeLabel, timeDropDownView].forEach { + [titleLabel, descriptionLabel, timeButton, amPmLabel, separatorView, timeLabel].forEach { addSubview($0) } } @@ -192,10 +164,5 @@ extension CallVanPostTimeView { $0.centerY.equalTo(timeButton) $0.leading.equalTo(separatorView.snp.trailing).offset(16) } - timeDropDownView.snp.makeConstraints { - $0.height.equalTo(153) - $0.top.equalTo(timeButton.snp.bottom).offset(12) - $0.leading.trailing.equalToSuperview().inset(24) - } } } diff --git a/Koin/Presentation/Core/ForceModifyUserViewController.swift b/Koin/Presentation/Core/ForceModifyUserViewController.swift index 208e027d..0a795165 100644 --- a/Koin/Presentation/Core/ForceModifyUserViewController.swift +++ b/Koin/Presentation/Core/ForceModifyUserViewController.swift @@ -146,8 +146,12 @@ extension ForceModifyUserViewController { } private func makeCategoryHostingController() -> UIViewController { + let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) - let categoryRootView = CategoryView(viewModel: CategoryViewModel(logAnalyticsEventUseCase: logAnalyticsEventUseCase)) + let categoryRootView = CategoryView( + viewModel: CategoryViewModel( + checkLoginUseCase: checkLoginUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase)) return CategoryHostingController(rootView: categoryRootView) } diff --git a/Koin/Presentation/Core/ForceUpdate/ForceUpdateViewController.swift b/Koin/Presentation/Core/ForceUpdate/ForceUpdateViewController.swift index 4b5d4e00..1d369bf4 100644 --- a/Koin/Presentation/Core/ForceUpdate/ForceUpdateViewController.swift +++ b/Koin/Presentation/Core/ForceUpdate/ForceUpdateViewController.swift @@ -86,11 +86,6 @@ final class ForceUpdateViewController: UIViewController, LottieAnimationManageab $0.backgroundColor = .clear } - private let updateModalViewController = UpdateModalViewController().then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - // MARK: - Initialization init(viewModel: ForceUpdateViewModel) { self.viewModel = viewModel @@ -140,19 +135,7 @@ final class ForceUpdateViewController: UIViewController, LottieAnimationManageab } }.store(in: &subscriptions) - updateModalViewController.openStoreButtonPublisher.sink { [weak self] in - self?.openStore() - }.store(in: &subscriptions) - setupCustomNotificationObservers() - - updateModalViewController.openStoreButtonPublisher.sink { [weak self] in - self?.inputSubject.send(.logEvent(EventParameter.EventLabel.ForceUpdate.alreadyUpdatePopup, .click, "스토어로 가기")) - }.store(in: &subscriptions) - - updateModalViewController.cancelButtonPublisher.sink { [weak self] in - self?.inputSubject.send(.logEvent(EventParameter.EventLabel.ForceUpdate.alreadyUpdatePopup, .click, "확인")) - }.store(in: &subscriptions) } private func setAddTarget() { @@ -197,7 +180,25 @@ extension ForceUpdateViewController { } @objc private func errorCheckButtonTapped() { - present(updateModalViewController, animated: true, completion: nil) + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: "이미 업데이트 하셨나요?", + subTitleText: "업데이트 이후에도 이 화면이 나타나는\n경우에는 스토어에서 코인을\n삭제 후 재설치 해 주세요." + ), + button: .buttons( + leftButtonTitle: "닫기", + leftButtonAction: { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.ForceUpdate.alreadyUpdatePopup, .click, "확인")) + }, + rightButtonTitle: "스토어 가기", + rightButtonAction: { [weak self] in + self?.openStore() + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.ForceUpdate.alreadyUpdatePopup, .click, "스토어로 가기")) + } + ) + )) + present(modalViewController, animated: true, completion: nil) inputSubject.send(.logEvent(EventParameter.EventLabel.ForceUpdate.forceUpdateAlreadyDone, .click, "이미업데이트")) } } diff --git a/Koin/Presentation/Core/ForceUpdate/UpdateModalViewController.swift b/Koin/Presentation/Core/ForceUpdate/UpdateModalViewController.swift deleted file mode 100644 index f4eefe28..00000000 --- a/Koin/Presentation/Core/ForceUpdate/UpdateModalViewController.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// UpdateModalViewController.swift -// koin -// -// Created by 김나훈 on 10/1/24. -// - -import Combine -import UIKit -import SnapKit - -final class UpdateModalViewController: UIViewController { - - // MARK: - Properties - let openStoreButtonPublisher = PassthroughSubject() - let cancelButtonPublisher = PassthroughSubject() - - // MARK: - UI Components - private let messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 18) - $0.text = "이미 업데이트 하셨나요?" - } - - private let subMessageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 13) - $0.textColor = UIColor.appColor(.neutral600) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - let text = "업데이트 이후에도 이 화면이 나타나는\n경우에는 스토어에서 코인을\n삭제 후 재설치 해 주세요." - let attributedString = NSMutableAttributedString(string: text) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - $0.attributedText = attributedString - $0.numberOfLines = 3 - $0.textAlignment = .center - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("닫기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - private let openStoreButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.new500) - $0.setTitle("스토어 가기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - private let containerView = UIView().then { - $0.backgroundColor = .white - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - // MARK: - Life Cycle - override func viewDidLoad() { - super.viewDidLoad() - configureView() - setAddTarget() - } - - private func setAddTarget() { - openStoreButton.addTarget(self, action: #selector(openStoreButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } -} - -// MARK: - @objc -extension UpdateModalViewController { - @objc private func closeButtonTapped() { - dismiss(animated: true, completion: nil) - cancelButtonPublisher.send() - } - - @objc private func openStoreButtonTapped() { - dismiss(animated: true, completion: nil) - openStoreButtonPublisher.send(()) - } -} - -// MARK: - UI Function -extension UpdateModalViewController { - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - - [messageLabel, subMessageLabel, closeButton, openStoreButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { - $0.centerX.equalTo(view.snp.centerX) - $0.centerY.equalTo(view.snp.centerY) - $0.width.equalTo(301) - $0.height.equalTo(210) - } - - messageLabel.snp.makeConstraints { - $0.top.equalTo(containerView.snp.top).offset(24) - $0.centerX.equalTo(containerView.snp.centerX) - } - - subMessageLabel.snp.makeConstraints { - $0.top.equalTo(messageLabel.snp.bottom).offset(16) - $0.centerX.equalTo(containerView.snp.centerX) - } - - closeButton.snp.makeConstraints { - $0.top.equalTo(subMessageLabel.snp.bottom).offset(24) - $0.trailing.equalTo(containerView.snp.centerX).offset(-4) - $0.width.equalTo(114.5) - $0.height.equalTo(48) - } - - openStoreButton.snp.makeConstraints { - $0.top.equalTo(subMessageLabel.snp.bottom).offset(24) - $0.leading.equalTo(containerView.snp.centerX).offset(4) - $0.width.equalTo(114.5) - $0.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } -} diff --git a/Koin/Presentation/Dining/Dining/DiningViewController.swift b/Koin/Presentation/Dining/Dining/DiningViewController.swift index 21d81b16..4a6e3f5f 100644 --- a/Koin/Presentation/Dining/Dining/DiningViewController.swift +++ b/Koin/Presentation/Dining/Dining/DiningViewController.swift @@ -14,8 +14,8 @@ final class DiningViewController: UIViewController { // MARK: - Properties private let viewModel: DiningViewModel - private let inputSubject: PassthroughSubject = .init() private var subscriptions: Set = [] + let inputSubject: PassthroughSubject = .init() private let refreshControl = UIRefreshControl() private var viewDidAppeared = false @@ -33,7 +33,7 @@ final class DiningViewController: UIViewController { } }() - private let diningTypeSegmentControl = UISegmentedControl().then { + let diningTypeSegmentControl = UISegmentedControl().then { $0.setBackgroundImage(UIImage(), for: .normal, barMetrics: .default) $0.setDividerImage(UIImage(), forLeftSegmentState: .normal, rightSegmentState: .normal, barMetrics: .default) $0.insertSegment(withTitle: "아침", at: 0, animated: true) @@ -62,7 +62,7 @@ final class DiningViewController: UIViewController { $0.layer.applySketchShadow(color: .appColor(.neutral800), alpha: 0.02, x: 0, y: 1, blur: 1, spread: 0) } - private let diningListCollectionView: DiningCollectionView = { + let diningListCollectionView: DiningCollectionView = { let flowLayout = UICollectionViewFlowLayout().then { $0.scrollDirection = .vertical } @@ -88,13 +88,6 @@ final class DiningViewController: UIViewController { $0.textAlignment = .center } - private let diningNotiContentViewController = DiningNotiContentViewController() - - private let diningLikeLoginModalViewController = ModalViewController(width: 301, height: 230, paddingBetweenLabels: 8, title: "더 맛있는 학식을 먹는 방법,\n로그인하고 좋아요를 남겨주세요!", subTitle: "여러분의 좋아요가 영양사님이 더 나은,\n식단을 제공할 수 있도록 도와줍니다.", titleColor: .appColor(.neutral700), subTitleColor: .appColor(.gray)).then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - // MARK: - Initialization init(viewModel: DiningViewModel) { @@ -138,11 +131,7 @@ final class DiningViewController: UIViewController { super.viewDidAppear(true) checkAndShowBottomSheet() if viewDidAppeared { - switch diningTypeSegmentControl.selectedSegmentIndex { - case 0: inputSubject.send(.updateDisplayDateTime(nil, .breakfast)) - case 1: inputSubject.send(.updateDisplayDateTime(nil, .lunch)) - default: inputSubject.send(.updateDisplayDateTime(nil, .dinner)) - } + inputSubject.send(.updateDisplayDateTime(nil, currentDiningType)) } viewDidAppeared = true @@ -158,7 +147,7 @@ final class DiningViewController: UIViewController { private func bind() { let outputSubject = viewModel.transform(with: inputSubject.eraseToAnyPublisher()) outputSubject.receive(on: DispatchQueue.main).sink { [weak self] output in - guard let strongSelf = self else { return } + guard self != nil else { return } switch output { case let .updateDiningList(list, diningType): self?.setDiningList(list) @@ -168,8 +157,6 @@ final class DiningViewController: UIViewController { case let .showBottomSheet((soldOutIsOn, imageUplloadisOn)): self?.showBottomSheet((soldOutIsOn, imageUplloadisOn)) UserDefaults.standard.set(true, forKey: "hasShownBottomSheet") - case .showLoginModal: - self?.present(strongSelf.diningLikeLoginModalViewController, animated: true, completion: nil) } }.store(in: &subscriptions) @@ -186,8 +173,10 @@ final class DiningViewController: UIViewController { zoomedImageViewController.setImage(tappedDiningImage) self.present(zoomedImageViewController, animated: true, completion: nil) - inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.menuImage, .click, "\(self.getCurrentDiningType())_\(tappedPlaceText)")) - + if let currentDiningType = self.currentDiningType { + inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.menuImage, .click, "\(currentDiningType.name)_\(tappedPlaceText)")) + } + }.store(in: &subscriptions) diningListCollectionView.shareButtonPublisher.sink { [weak self] item in @@ -196,25 +185,8 @@ final class DiningViewController: UIViewController { }.store(in: &subscriptions) diningListCollectionView.logScrollPublisher.sink { [weak self] _ in - guard let self = self else { return } - self.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.menuTime, .scroll, self.getCurrentDiningType())) - }.store(in: &subscriptions) - - diningNotiContentViewController.soldOutSwitchPublisher.sink { [weak self] isOn in - self?.inputSubject.send(.changeNoti(isOn, .diningSoldOut)) - }.store(in: &subscriptions) - - diningNotiContentViewController.imageUploadSwitchPublisher.sink { [weak self] isOn in - self?.inputSubject.send(.changeNoti(isOn, .diningImageUpload)) - }.store(in: &subscriptions) - - diningNotiContentViewController.shortcutButtonPublisher.sink { [weak self] in - self?.navigateToNoti() - self?.diningNotiContentViewController.dissmissView() - }.store(in: &subscriptions) - - diningLikeLoginModalViewController.rightButtonPublisher.sink { [weak self] in - self?.navigateToLogin() + guard let self = self, let currentDiningType = self.currentDiningType else { return } + self.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.menuTime, .scroll, currentDiningType.name)) }.store(in: &subscriptions) NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification).sink { [weak self] _ in @@ -241,29 +213,29 @@ extension DiningViewController { diningListCollectionView.addGestureRecognizer(swipeRightGesture) } - @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) { let currentSegmentIndex = diningTypeSegmentControl.selectedSegmentIndex - if gesture.direction == .left { - if currentSegmentIndex < diningTypeSegmentControl.numberOfSegments - 1 { - diningTypeSegmentControl.selectedSegmentIndex = currentSegmentIndex + 1 - } - } else if gesture.direction == .right { - if currentSegmentIndex > 0 { - diningTypeSegmentControl.selectedSegmentIndex = currentSegmentIndex - 1 - } + let nextSegmentIndex: Int + + switch gesture.direction { + case .left: nextSegmentIndex = currentSegmentIndex + 1 + case .right: nextSegmentIndex = currentSegmentIndex - 1 + default: return } + + guard (0.. String { - switch diningTypeSegmentControl.selectedSegmentIndex { - case 0: return "아침" - case 1: return "점심" - default: return "저녁" - } + private var currentDiningType: DiningType? { + DiningType(segmentIndex: diningTypeSegmentControl.selectedSegmentIndex) } } diff --git a/Koin/Presentation/Dining/Dining/DiningViewModel.swift b/Koin/Presentation/Dining/Dining/DiningViewModel.swift index b574f5b3..5564d33a 100644 --- a/Koin/Presentation/Dining/Dining/DiningViewModel.swift +++ b/Koin/Presentation/Dining/Dining/DiningViewModel.swift @@ -24,7 +24,6 @@ final class DiningViewModel: ViewModelProtocol { case updateDiningList([DiningItem], DiningType) case initCalendar(Date) case showBottomSheet((Bool, Bool)) - case showLoginModal } private let outputSubject = PassthroughSubject() diff --git a/Koin/Presentation/Dining/Dining/SubViews/DiningNotiContentViewController.swift b/Koin/Presentation/Dining/Dining/SubViews/DiningNotiContentViewController.swift index 0e20d576..189ccf3a 100644 --- a/Koin/Presentation/Dining/Dining/SubViews/DiningNotiContentViewController.swift +++ b/Koin/Presentation/Dining/Dining/SubViews/DiningNotiContentViewController.swift @@ -5,16 +5,13 @@ // Created by 김나훈 on 7/29/24. // -import Combine import Then import UIKit final class DiningNotiContentViewController: UIViewController { - - - let soldOutSwitchPublisher = PassthroughSubject() - let imageUploadSwitchPublisher = PassthroughSubject() - let shortcutButtonPublisher = PassthroughSubject() + private let onSoldOutSwitchChanged: (Bool) -> Void + private let onImageUploadSwitchChanged: (Bool) -> Void + private let onShortcutButtonTapped: () -> Void private let diningNotiLabel = UILabel().then { $0.font = UIFont.appFont(.pretendardBold, size: 18) @@ -67,7 +64,14 @@ final class DiningNotiContentViewController: UIViewController { $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 14) } - init() { + init( + onSoldOutSwitchChanged: @escaping (Bool) -> Void, + onImageUploadSwitchChanged: @escaping (Bool) -> Void, + onShortcutButtonTapped: @escaping () -> Void + ) { + self.onSoldOutSwitchChanged = onSoldOutSwitchChanged + self.onImageUploadSwitchChanged = onImageUploadSwitchChanged + self.onShortcutButtonTapped = onShortcutButtonTapped super.init(nibName: nil, bundle: nil) } @@ -92,21 +96,17 @@ extension DiningNotiContentViewController { imageUploadSwitch.isOn = isOn.1 } - func dissmissView() { - dismiss(animated: true, completion: nil) - } - @objc private func buttonTapped(_ sender: UIButton) { switch sender { - case notiShortcutButton: shortcutButtonPublisher.send(()) + case notiShortcutButton: dismiss(animated: true, completion: onShortcutButtonTapped) default: dismiss(animated: true, completion: nil) } } @objc private func switchToggled(_ sender: UISwitch) { switch sender { - case soldOutSwitch: soldOutSwitchPublisher.send(sender.isOn) - default: imageUploadSwitchPublisher.send(sender.isOn) + case soldOutSwitch: onSoldOutSwitchChanged(sender.isOn) + default: onImageUploadSwitchChanged(sender.isOn) } } } @@ -165,4 +165,3 @@ extension DiningNotiContentViewController { self.view.backgroundColor = .systemBackground } } - diff --git a/Koin/Presentation/Home/Category/CategoryHostingController.swift b/Koin/Presentation/Home/Category/CategoryHostingController.swift index 0230097f..5d6334d1 100644 --- a/Koin/Presentation/Home/Category/CategoryHostingController.swift +++ b/Koin/Presentation/Home/Category/CategoryHostingController.swift @@ -44,10 +44,22 @@ final class CategoryHostingController: UIHostingController, Hostin navigationController?.pushViewController(makeBusSearchViewController(), animated: true) case .showCallVan: navigationController?.pushViewController(makeCallVanListViewController(), animated: true) + case .showChatList: + navigationController?.pushViewController(makeChatListViewController(), animated: true) case .showLand: navigationController?.pushViewController(makeLandViewController(), animated: true) case .showBusiness: presentBusiness() + case .showRecruit: + showRecruit() + + case .showLoginToast: + showToastMessageWithButton( + message: "로그인이 필요한 기능입니다.", + buttonTitle: "로그인" + ) { [weak self] in + self?.navigateToLogin() + } } } } @@ -174,6 +186,10 @@ extension CategoryHostingController { ) return CallVanListViewController(viewModel: viewModel) } + + private func makeChatListViewController() -> UIViewController { + return LostItemChatListTableViewController(viewModel: LostItemChatListTableViewModel()) + } private func makeLandViewController() -> UIViewController { let landService = DefaultLandService() @@ -190,4 +206,14 @@ extension CategoryHostingController { present(safariViewController, animated: true) } } + + private func showRecruit() { + guard var components = URLComponents(string: Bundle.main.baseUrl), + let host = components.host else { return } + components.host = host.hasPrefix("api.") ? String(host.dropFirst("api.".count)) : host + components.path = "/team" + guard let url = components.url else { return } + let safariViewController = SFSafariViewController(url: url) + present(safariViewController, animated: true) + } } diff --git a/Koin/Presentation/Home/Category/CategoryView.swift b/Koin/Presentation/Home/Category/CategoryView.swift index 93de61f0..eb4bcc4d 100644 --- a/Koin/Presentation/Home/Category/CategoryView.swift +++ b/Koin/Presentation/Home/Category/CategoryView.swift @@ -15,14 +15,18 @@ struct CategoryView: ActionBindableView { case showTimetable case showLostItem case showFacility + case showDepartment case showDining case showShop case showBusTimetable case showBusRoute case showCallVan + case showChatList case showLand case showBusiness - case showDepartment + case showRecruit + + case showLoginToast } // MARK: - Properties @@ -48,8 +52,8 @@ struct CategoryView: ActionBindableView { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 16) { HStack(spacing: 12) { - CategoryFeaturedButton(item: .timetable) { - didTapItem(.timetable) + CategoryFeaturedButton(item: .recruit) { + didTapItem(.recruit) } CategoryFeaturedButton(item: .lostItem) { didTapItem(.lostItem) @@ -61,7 +65,8 @@ struct CategoryView: ActionBindableView { .facility, .department, .dining, - .shop + .shop, + .timetable ], action: { item in didTapItem(item) @@ -81,8 +86,9 @@ struct CategoryView: ActionBindableView { CategorySection( title: "기타", items: [ + .chat, .land, - .business, + .business ], action: { item in didTapItem(item) @@ -96,17 +102,28 @@ struct CategoryView: ActionBindableView { } .scrollIndicators(.hidden) .background(Color.appColor(.newBackground)) + .onAppear { + viewModel.execute(.checkAuth) + } } } private extension CategoryView { private func didTapItem(_ item: HomeCategoryItem) { + if case item = .chat { + guard viewModel.isLoggedIn else { + sendAction(.showLoginToast) + return + } + } + let action = action(for: item) sendAction(action) - let loggingInfo = loggingInfo(for: action) - viewModel.execute(.logEvent(loggingInfo.label, .click, loggingInfo.value)) + if let loggingInfo = loggingInfo(for: action) { + viewModel.execute(.logEvent(loggingInfo.label, .click, loggingInfo.value)) + } } private func action(for item: HomeCategoryItem) -> Action { @@ -129,17 +146,21 @@ private extension CategoryView { return .showBusRoute case .callVan: return .showCallVan + case .chat: + return .showChatList case .land: return .showLand case .business: return .showBusiness + case .recruit: + return .showRecruit } } - private func loggingInfo(for action: Action) -> (label: EventParameter.EventLabel.Campus, value: String) { + private func loggingInfo(for action: Action) -> (label: EventParameter.EventLabel.Campus, value: String)? { switch action { - case .showTimetable: - return (.categoryTimetable, "시간표") + case .showRecruit: + return nil // TODO: 로깅 추가 case .showLostItem: return (.categoryLostProperty, "분실물") case .showFacility: @@ -150,16 +171,22 @@ private extension CategoryView { return (.categoryCampus, "식단") case .showShop: return (.categoryCampus, "주변상점") + case .showTimetable: + return (.categoryTimetable, "시간표") // TODO: 로깅 수정 case .showBusTimetable: return (.categoryTransportation, "버스 시간표") case .showBusRoute: return (.categoryTransportation, "교통편 조회하기") case .showCallVan: return (.categoryTransportation, "콜밴팟 모집") + case .showChatList: + return (.categoryEtc, "채팅") case .showLand: return (.categoryEtc, "복덕방") case .showBusiness: return (.categoryEtc, "코인 for Business") + case .showLoginToast: + return nil } } } diff --git a/Koin/Presentation/Home/Category/CategoryViewModel.swift b/Koin/Presentation/Home/Category/CategoryViewModel.swift index 1f7e8c72..1343db14 100644 --- a/Koin/Presentation/Home/Category/CategoryViewModel.swift +++ b/Koin/Presentation/Home/Category/CategoryViewModel.swift @@ -7,22 +7,36 @@ import Foundation import Observation +import Combine @Observable @MainActor final class CategoryViewModel: SwiftUIViewModelProtocol { enum Input { + case checkAuth case logEvent(EventLabelType, EventParameter.EventCategory, Any) } + private let checkLoginUseCase: CheckLoginUseCase private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase + private(set) var isLoggedIn: Bool = false + private var subscriptions = Set() + - init(logAnalyticsEventUseCase: LogAnalyticsEventUseCase) { + init( + checkLoginUseCase: CheckLoginUseCase, + logAnalyticsEventUseCase: LogAnalyticsEventUseCase + ) { + self.checkLoginUseCase = checkLoginUseCase self.logAnalyticsEventUseCase = logAnalyticsEventUseCase } func execute(_ input: Input) { switch input { + case .checkAuth: + checkLoginUseCase.execute().sink { [weak self] isLoggedIn in + self?.isLoggedIn = isLoggedIn + }.store(in: &subscriptions) case let .logEvent(label, category, value): logAnalyticsEventUseCase.execute(label: label, category: category, value: value) } diff --git a/Koin/Presentation/Home/Home/HomeHostingController.swift b/Koin/Presentation/Home/Home/HomeHostingController.swift index 4ecac938..a9e36210 100644 --- a/Koin/Presentation/Home/Home/HomeHostingController.swift +++ b/Koin/Presentation/Home/Home/HomeHostingController.swift @@ -243,7 +243,7 @@ extension HomeHostingController { return } navigationController?.pushViewController( - ChatListTableViewController(viewModel: ChatListTableViewModel()), + LostItemChatListTableViewController(viewModel: LostItemChatListTableViewModel()), animated: true ) case "lostitem": diff --git a/Koin/Presentation/Home/Notification/NotificationViewController.swift b/Koin/Presentation/Home/Notification/NotificationViewController.swift index 9e48cf44..f745f104 100644 --- a/Koin/Presentation/Home/Notification/NotificationViewController.swift +++ b/Koin/Presentation/Home/Notification/NotificationViewController.swift @@ -8,7 +8,6 @@ import UIKit import Combine import SnapKit -import Then final class NotificationViewController: UIViewController { @@ -18,12 +17,7 @@ final class NotificationViewController: UIViewController { private var subscriptions = Set() // MARK: - UI Components - private let notificationTableView = NotificationTableView() - private let refreshControl = UIRefreshControl() - - private let loadingIndicator = UIActivityIndicatorView(style: .medium).then { - $0.hidesWhenStopped = true - } + private let notificationListView = NotificationListView() // MARK: - Initialization init(viewModel: NotificationViewModel) { @@ -39,10 +33,9 @@ final class NotificationViewController: UIViewController { super.viewDidLoad() configureView() configureNavigationBar() - setAddTargets() bind() + notificationListView.startLoading() inputSubject.send(.viewDidLoad) - loadingIndicator.startAnimating() title = "알림" } @@ -63,74 +56,42 @@ private extension NotificationViewController { switch event { case .updateNotifications(let notifications): - self.notificationTableView.update(notifications: notifications) - self.updateStateViews(isEmpty: notifications.isEmpty) + notificationListView.update(items: notifications.map { NotificationRowModel(from: $0) }) + case .selectedNotification(let notification): + handleNavigation(notification) case .showToast(let message): showToastMessage(message: message) } } .store(in: &subscriptions) - notificationTableView.deletePublisher + notificationListView.deletePublisher .sink { [weak self] id in guard let self else { return } self.inputSubject.send(.deleteNotification(id: id)) - self.updateStateViews(isEmpty: self.notificationTableView.isEmpty) self.showToastMessage(message: "알림이 삭제되었습니다.") self.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationListDelete, .click, "알림 삭제")) } .store(in: &subscriptions) - notificationTableView.tapNotificationPublisher - .sink { [weak self] item in - self?.inputSubject.send(.markAsRead(id: item.id)) - self?.makeLogEvent(notification: item) - self?.handleNavigation(item) + notificationListView.itemTappedPublisher + .sink { [weak self] id in + self?.inputSubject.send(.selectNotification(id: id)) } .store(in: &subscriptions) - } -} - -extension NotificationViewController { - private func makeLogEvent(notification: NotificationItem) { - let logValue: String - switch notification.appPath { - case .shop: - logValue = "주변상점" - case .dining: - logValue = "식단" - case .keyword: - logValue = "키워드알림" - case .chat: - logValue = "분실물 채팅" - case .callvan: - logValue = "콜밴팟" - case .callvanChat: - logValue = "콜밴팟 채팅" - } - inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationList, .click, logValue)) - } -} -extension NotificationViewController { - private func updateStateViews(isEmpty: Bool) { - loadingIndicator.stopAnimating() - refreshControl.endRefreshing() - - UIView.animate( - withDuration: 0.2, - delay: 0, - options: [.curveEaseInOut, .beginFromCurrentState] - ) { [weak self] in - self?.notificationTableView.backgroundView?.alpha = isEmpty ? 1 : 0 - } + notificationListView.refreshPublisher + .sink { [weak self] in + self?.inputSubject.send(.reload) + } + .store(in: &subscriptions) } } // MARK: - Navigation extension NotificationViewController { - private func handleNavigation(_ item: NotificationItem) { + private func handleNavigation(_ item: NotificationHistoryItem) { guard let uri: String = item.uri, let parsedQuery = parseQuery(uri: uri) else { return @@ -195,8 +156,8 @@ extension NotificationViewController { } private func navigateToChat(articleId: Int, chatRoomId: Int) { - let viewModel = ChatViewModel(articleId: articleId, chatRoomId: chatRoomId, articleTitle: nil) - let viewController = ChatViewController(viewModel: viewModel) + let viewModel = LostItemChatViewModel(articleId: articleId, chatRoomId: chatRoomId, articleTitle: nil) + let viewController = LostItemChatViewController(viewModel: viewModel) navigationController?.pushViewController(viewController, animated: true) } @@ -213,13 +174,13 @@ extension NotificationViewController { private func navigateToLostItemData(lostItemId: Int) { let userRepository = DefaultUserRepository(service: DefaultUserService()) let lostItemRepository = DefaultLostItemRepository(service: DefaultLostItemService()) - let chatRepository = DefaultChatRepository(service: DefaultChatService()) + let chatRepository = DefaultLostItemRepository(service: DefaultLostItemService()) let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: userRepository) let fetchLostItemDataUseCase = DefaultFetchLostItemDataUseCase(repository: lostItemRepository) let fetchLostItemListUseCase = DefaultFetchLostItemListUseCase(repository: lostItemRepository) let changeLostItemStateUseCase = DefaultChangeLostItemStateUseCase(repository: lostItemRepository) let deleteLostItemUseCase = DefaultDeleteLostItemUseCase(repository: lostItemRepository) - let createChatRoomUseCase = DefaultCreateChatRoomUseCase(chatRepository: chatRepository) + let createChatRoomUseCase = DefaultLostItemCreateChatRoomUseCase(chatRepository: chatRepository) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = LostItemDataViewModel( checkLoginUseCase: checkLoginUseCase, @@ -341,21 +302,16 @@ private extension NotificationViewController { showPopUpView() } - @objc func didPullToRefresh() { - inputSubject.send(.reload) - } - private func showPopUpView() { let popUpViewController = NotificationPopUpViewController( markAllAsRead: { [weak self] in self?.inputSubject.send(.markAllAsRead) - self?.notificationTableView.markAllAsRead() + self?.notificationListView.markAllAsRead() self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationListReadAll, .click, "모두 읽음으로 표시")) }, deleteAll: { [weak self] in self?.inputSubject.send(.deleteAllNotifications) - self?.notificationTableView.deleteAll() - self?.updateStateViews(isEmpty: true) + self?.notificationListView.deleteAll() self?.showToastMessage(message: "알림이 삭제되었습니다.") self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.notificationListDeleteAll, .click, "알림 전체 삭제")) } @@ -371,11 +327,6 @@ private extension NotificationViewController { // MARK: - Configure private extension NotificationViewController { - - private func setAddTargets() { - refreshControl.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged) - } - private func configureNavigationBar() { let rightBarButtonItem = UIBarButtonItem( @@ -395,28 +346,16 @@ private extension NotificationViewController { private func setUpStyles() { view.backgroundColor = UIColor.ColorSystem.Neutral.gray0 - - notificationTableView.refreshControl = refreshControl - - notificationTableView.backgroundView = NotificationEmptyBackgroundView().then { - $0.alpha = 0 - } } private func setUpLayouts() { - [notificationTableView, loadingIndicator].forEach { - view.addSubview($0) - } + view.addSubview(notificationListView) } private func setUpConstraints() { - notificationTableView.snp.makeConstraints { + notificationListView.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide.snp.top) $0.leading.trailing.bottom.equalToSuperview() } - - loadingIndicator.snp.makeConstraints { - $0.center.equalToSuperview() - } } } diff --git a/Koin/Presentation/Home/Notification/NotificationViewModel.swift b/Koin/Presentation/Home/Notification/NotificationViewModel.swift index 6c0f6d92..d8063747 100644 --- a/Koin/Presentation/Home/Notification/NotificationViewModel.swift +++ b/Koin/Presentation/Home/Notification/NotificationViewModel.swift @@ -13,15 +13,16 @@ final class NotificationViewModel: ViewModelProtocol { enum Input { case viewDidLoad case reload + case selectNotification(id: String) case deleteNotification(id: String) case deleteAllNotifications - case markAsRead(id: String) case markAllAsRead case logEvent(EventLabelType, EventParameter.EventCategory, Any) } enum Output { - case updateNotifications([NotificationItem]) + case updateNotifications([NotificationHistoryItem]) + case selectedNotification(NotificationHistoryItem) case showToast(String) } @@ -32,6 +33,7 @@ final class NotificationViewModel: ViewModelProtocol { private let updateNotificationHistoryUseCase: UpdateNotificationHistoryUseCase private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase private let outputSubject = PassthroughSubject() + private var notificationHistoryItems: [NotificationHistoryItem] = [] private var subscriptions = Set() // MARK: - Initializer @@ -56,12 +58,12 @@ final class NotificationViewModel: ViewModelProtocol { switch input { case .viewDidLoad, .reload: self?.loadNotifications() + case .selectNotification(let id): + self?.selectNotification(id: id) case .deleteNotification(let id): self?.deleteNotification(id: id) case .deleteAllNotifications: self?.deleteAllNotifications() - case .markAsRead(let id): - self?.markAsRead(id: id) case .markAllAsRead: self?.markAllAsRead() case let .logEvent(label, category, value): @@ -82,6 +84,7 @@ private extension NotificationViewModel { Task { do { let notifications = try await fetchNotificationHistoryUseCase.execute() + self.notificationHistoryItems = notifications outputSubject.send(.updateNotifications(notifications)) } catch { outputSubject.send(.showToast(error.localizedDescription)) @@ -89,27 +92,64 @@ private extension NotificationViewModel { } } + private func selectNotification(id: String) { + guard let notification = notificationHistoryItems.first(where: { $0.id == id }), + let logValue = notification.logValue else { + return + } + markAsRead(id: id) + + outputSubject.send(.selectedNotification(notification)) + + makeLogAnalyticsEvent( + label: EventParameter.EventLabel.Campus.notificationList, + category: .click, + value: logValue + ) + } + private func deleteNotification(id: String) { + notificationHistoryItems.removeAll { $0.id == id } Task { - try? await deleteNotificationHistoryUseCase.delete(id: id) + do { + try await deleteNotificationHistoryUseCase.delete(id: id) + } catch { + outputSubject.send(.showToast(error.localizedDescription)) + } } } private func deleteAllNotifications() { + notificationHistoryItems.removeAll() Task { - try? await deleteNotificationHistoryUseCase.deleteAll() + do { + try await deleteNotificationHistoryUseCase.deleteAll() + } catch { + outputSubject.send(.showToast(error.localizedDescription)) + } } } private func markAsRead(id: String) { + if let index = notificationHistoryItems.firstIndex(where: { $0.id == id }) { + notificationHistoryItems[index].isRead = true + } + Task { try? await updateNotificationHistoryUseCase.markAsRead(id: id) } } private func markAllAsRead() { + for index in notificationHistoryItems.indices { + notificationHistoryItems[index].isRead = true + } Task { - try? await updateNotificationHistoryUseCase.markAllAsRead() + do { + try await updateNotificationHistoryUseCase.markAllAsRead() + } catch { + outputSubject.send(.showToast(error.localizedDescription)) + } } } diff --git a/Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift b/Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift new file mode 100644 index 00000000..26b2018b --- /dev/null +++ b/Koin/Presentation/Home/Notification/Support/NotificationRowModel+NotificationHistoryItem.swift @@ -0,0 +1,19 @@ +// +// NotificationRowModel+NotificationHistoryItem.swift +// koin +// +// Created by 홍기정 on 8/19/26. +// + +extension NotificationRowModel { + init(from item: NotificationHistoryItem) { + self.init( + id: item.id, + isRead: item.isRead, + icon: item.icon, + title: item.title, + content: item.content, + dateText: item.dateText + ) + } +} diff --git a/Koin/Presentation/Login/FindId/StateButton.swift b/Koin/Presentation/Login/FindId/StateButton.swift index e129bfcb..929c0b9a 100644 --- a/Koin/Presentation/Login/FindId/StateButton.swift +++ b/Koin/Presentation/Login/FindId/StateButton.swift @@ -31,11 +31,11 @@ final class StateButton: UIButton { self.setTitleColor(UIColor.appColor(.neutral600), for: .normal) self.isEnabled = false case .usable: - self.backgroundColor = UIColor.appColor(.primary500) + self.backgroundColor = UIColor.appColor(.new500) self.setTitleColor(.white, for: .normal) self.isEnabled = true case .retry: - self.backgroundColor = UIColor.appColor(.sub500) + self.backgroundColor = UIColor.appColor(.new600) self.setTitleColor(.white, for: .normal) self.isEnabled = false } diff --git a/Koin/Presentation/Login/FindId/StateView.swift b/Koin/Presentation/Login/FindId/StateView.swift index e7b71c03..d9348b05 100644 --- a/Koin/Presentation/Login/FindId/StateView.swift +++ b/Koin/Presentation/Login/FindId/StateView.swift @@ -37,13 +37,13 @@ final class StateView: UIView { messageLabel.text = message switch state { case .success: - imageView.image = UIImage(named: "successCircle") + imageView.image = UIImage.appImage(asset: .successCircle) messageLabel.textColor = UIColor.appColor(.success700) case .warning: - imageView.image = UIImage(named: "warningOrange") - messageLabel.textColor = UIColor.appColor(.sub500) + imageView.image = UIImage.appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600)) + messageLabel.textColor = UIColor.appColor(.new600) case .dangerous: - imageView.image = UIImage(named: "warningRed") + imageView.image = UIImage.appImage(asset: .warningRed) messageLabel.textColor = UIColor.appColor(.danger700) } } diff --git a/Koin/Presentation/Login/FindId/ViewControllers/FindPhoneIdViewController.swift b/Koin/Presentation/Login/FindId/ViewControllers/FindPhoneIdViewController.swift index 7f87f2cd..1957d876 100644 --- a/Koin/Presentation/Login/FindId/ViewControllers/FindPhoneIdViewController.swift +++ b/Koin/Presentation/Login/FindId/ViewControllers/FindPhoneIdViewController.swift @@ -28,7 +28,13 @@ final class FindPhoneIdViewController: UIViewController { $0.text = certType == .phone ? "휴대전화 번호" : "이메일" } - private lazy var phoneNumberTextField = DefaultTextField(placeholder: certType == .phone ? "- 없이 번호를 입력해 주세요." : "등록된 이메일을 입력해 주세요.", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)) + private lazy var phoneNumberTextField = DefaultTextField( + placeholder: certType == .phone ? "- 없이 번호를 입력해 주세요." : "등록된 이메일을 입력해 주세요.", + placeholderColor: UIColor.appColor(.neutral400), + font: UIFont.appFont(.pretendardRegular, size: 14) + ).then { + $0.keyboardType = certType == .phone ? .numberPad : .emailAddress + } private let sendButton = StateButton().then { $0.setState(state: .unusable) @@ -41,7 +47,7 @@ final class FindPhoneIdViewController: UIViewController { private let changeButton = UIButton().then { $0.setTitle("이메일로 찾기", for: .normal) - $0.setTitleColor(UIColor.appColor(.primary500), for: .normal) + $0.setTitleColor(UIColor.appColor(.new500), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 12) } @@ -73,6 +79,8 @@ final class FindPhoneIdViewController: UIViewController { private let saveButton = StateButton(font: UIFont.appFont(.pretendardMedium, size: 16)).then { $0.setState(state: .unusable) $0.setTitle("저장", for: .normal) + }.then { + $0.layer.cornerRadius = 8 } init(viewModel: FindIdViewModel, certType: CertType = .phone) { @@ -172,6 +180,10 @@ extension FindPhoneIdViewController { } } @objc private func sendButtonTapped() { + [helpLabel, changeButton].forEach { + $0.isHidden = true + } + if certType == .phone { viewModel.sendVerificationCode(phoneNumber: phoneNumberTextField.text ?? "") } else { @@ -242,15 +254,15 @@ extension FindPhoneIdViewController { phoneStateView.snp.makeConstraints { $0.top.equalTo(phoneNumberTextField.snp.bottom).offset(5) $0.leading.equalTo(phoneNumberTextField) - $0.height.equalTo(19) } helpLabel.snp.makeConstraints { - $0.top.equalTo(phoneStateView.snp.bottom).offset(5) + $0.top.equalTo(phoneNumberTextField.snp.bottom).offset(5) $0.leading.equalTo(phoneNumberLabel) + $0.height.equalTo(19) } changeButton.snp.makeConstraints { $0.leading.equalTo(helpLabel.snp.trailing).offset(5) - $0.top.bottom.equalTo(helpLabel) + $0.centerY.equalTo(helpLabel) $0.width.equalTo(66) $0.height.equalTo(19) } diff --git a/Koin/Presentation/Login/FindId/ViewControllers/FoundIdViewController.swift b/Koin/Presentation/Login/FindId/ViewControllers/FoundIdViewController.swift index 3fbd76be..0412df99 100644 --- a/Koin/Presentation/Login/FindId/ViewControllers/FoundIdViewController.swift +++ b/Koin/Presentation/Login/FindId/ViewControllers/FoundIdViewController.swift @@ -23,18 +23,20 @@ final class FoundIdViewController: UIViewController { private let subMessageLabel = UILabel() private let loginButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.sub500) + $0.backgroundColor = UIColor.appColor(.new500) $0.setTitle("로그인 바로가기", for: .normal) $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 15) + $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) $0.layer.cornerRadius = 8 } private let findPasswordButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) + $0.backgroundColor = UIColor.appColor(.neutral0) $0.setTitle("비밀번호 찾기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 15) + $0.setTitleColor(UIColor.appColor(.new500), for: .normal) + $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.layer.borderWidth = 1 $0.layer.cornerRadius = 8 } @@ -181,8 +183,12 @@ extension FoundIdViewController { } private func makeCategoryHostingController() -> UIViewController { + let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) - let categoryRootView = CategoryView(viewModel: CategoryViewModel(logAnalyticsEventUseCase: logAnalyticsEventUseCase)) + let categoryRootView = CategoryView( + viewModel: CategoryViewModel( + checkLoginUseCase: checkLoginUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase)) return CategoryHostingController(rootView: categoryRootView) } @@ -256,7 +262,7 @@ extension FoundIdViewController { private func setupComponents() { messageLabel.font = UIFont.appFont(.pretendardBold, size: 24) - messageLabel.textColor = UIColor.appColor(.primary500) + messageLabel.textColor = UIColor.appColor(.new500) } private func setupUI() { diff --git a/Koin/Presentation/Login/FindPassword/ChangePasswordSuccessViewController.swift b/Koin/Presentation/Login/FindPassword/ChangePasswordSuccessViewController.swift index 70c49f54..b5a19637 100644 --- a/Koin/Presentation/Login/FindPassword/ChangePasswordSuccessViewController.swift +++ b/Koin/Presentation/Login/FindPassword/ChangePasswordSuccessViewController.swift @@ -15,7 +15,7 @@ final class ChangePasswordSuccessViewController: UIViewController { // MARK: - UI Components private let circleImageView = UIImageView().then { - $0.image = UIImage(named: "checkFilledCircle") + $0.image = .appImage(asset: .checkEmptyCircle)?.withTintColor(.appColor(.new600)) } private let messageLabel = UILabel().then { @@ -29,6 +29,8 @@ final class ChangePasswordSuccessViewController: UIViewController { private let goLoginButton = StateButton(font: UIFont.appFont(.pretendardBold, size: 15)).then { $0.setState(state: .usable) $0.setTitle("로그인 화면 바로가기", for: .normal) + }.then { + $0.layer.cornerRadius = 8 } init() { @@ -154,8 +156,12 @@ extension ChangePasswordSuccessViewController { } private func makeCategoryHostingController() -> UIViewController { + let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) - let categoryRootView = CategoryView(viewModel: CategoryViewModel(logAnalyticsEventUseCase: logAnalyticsEventUseCase)) + let categoryRootView = CategoryView( + viewModel: CategoryViewModel( + checkLoginUseCase: checkLoginUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase)) return CategoryHostingController(rootView: categoryRootView) } @@ -228,7 +234,7 @@ extension ChangePasswordSuccessViewController { private func setupComponents() { messageLabel.font = UIFont.appFont(.pretendardBold, size: 24) - messageLabel.textColor = UIColor.appColor(.primary500) + messageLabel.textColor = UIColor.appColor(.new500) subMessageLabel.font = UIFont.appFont(.pretendardMedium, size: 16) subMessageLabel.textColor = UIColor.appColor(.gray) } diff --git a/Koin/Presentation/Login/FindPassword/FindPasswordCertViewController.swift b/Koin/Presentation/Login/FindPassword/FindPasswordCertViewController.swift index de9a1d46..8a080609 100644 --- a/Koin/Presentation/Login/FindPassword/FindPasswordCertViewController.swift +++ b/Koin/Presentation/Login/FindPassword/FindPasswordCertViewController.swift @@ -25,19 +25,19 @@ final class FindPasswordCertViewController: UIViewController { private let stepTextLabel = UILabel().then { $0.text = "1. 계정 인증" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let stepLabel = UILabel().then { $0.text = "1 / 2" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral200) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 0.5 @@ -195,6 +195,9 @@ extension FindPasswordCertViewController { } } @objc private func sendButtonTapped() { + [helpLabel, changeButton].forEach { + $0.isHidden = true + } switch certType { case .phone: viewModel.sendVerificationCode() case .email: viewModel.sendVerificationEmail() @@ -283,7 +286,7 @@ extension FindPasswordCertViewController { $0.height.equalTo(32) } helpLabel.snp.makeConstraints { - $0.top.equalTo(phoneTextField.snp.bottom).offset(3) + $0.top.equalTo(phoneTextField.snp.bottom).offset(8) $0.leading.equalTo(phoneTextField) } changeButton.snp.makeConstraints { @@ -293,7 +296,7 @@ extension FindPasswordCertViewController { $0.height.equalTo(19) } phoneStateView.snp.makeConstraints { - $0.top.equalTo(helpLabel.snp.bottom).offset(4) + $0.top.equalTo(phoneTextField.snp.bottom).offset(8) $0.leading.equalTo(stepTextLabel) $0.height.equalTo(19) } @@ -336,7 +339,7 @@ extension FindPasswordCertViewController { helpLabel.font = UIFont.appFont(.pretendardRegular, size: 12) helpLabel.textColor = UIColor.appColor(.neutral500) changeButton.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 12) - changeButton.setTitleColor(UIColor.appColor(.primary500), for: .normal) + changeButton.setTitleColor(UIColor.appColor(.new500), for: .normal) } private func setUpTextFieldUnderline() { [idtextField, phoneTextField, certNumberTextField].forEach { diff --git a/Koin/Presentation/Login/FindPassword/FindPasswordChangeViewController.swift b/Koin/Presentation/Login/FindPassword/FindPasswordChangeViewController.swift index 415c780d..c5d233d4 100644 --- a/Koin/Presentation/Login/FindPassword/FindPasswordChangeViewController.swift +++ b/Koin/Presentation/Login/FindPassword/FindPasswordChangeViewController.swift @@ -19,19 +19,19 @@ final class FindPasswordChangeViewController: UIViewController { private let stepTextLabel = UILabel().then { $0.text = "2. 비밀번호 변경" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let stepLabel = UILabel().then { $0.text = "2 / 2" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral200) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 1 @@ -68,6 +68,8 @@ final class FindPasswordChangeViewController: UIViewController { private let nextButton = StateButton(font: UIFont.appFont(.pretendardMedium, size: 15)).then { $0.setTitle("다음", for: .normal) $0.setState(state: .unusable) + }.then { + $0.layer.cornerRadius = 8 } init(viewModel: FindPasswordViewModel, certType: FindPasswordCertViewController.CertType) { diff --git a/Koin/Presentation/Login/Login/LoginViewController.swift b/Koin/Presentation/Login/Login/LoginViewController.swift index f0a80c9f..f42f6c9e 100644 --- a/Koin/Presentation/Login/Login/LoginViewController.swift +++ b/Koin/Presentation/Login/Login/LoginViewController.swift @@ -8,23 +8,46 @@ import Combine import SafariServices import UIKit +import SnapKit final class LoginViewController: UIViewController { - - var completion: (() -> Void)? + // MARK: - Properties private let viewModel: LoginViewModel private let inputSubject: PassthroughSubject = .init() private var subscriptions: Set = [] // MARK: - UI Components + private let scrollView = UIScrollView() + + private let contentView = UIView() + + private let contentTopPaddingLayoutGuide = UILayoutGuide() + private let contentLayoutGuide = UILayoutGuide() + private let contentBottomPaddingLayoutGuide = UILayoutGuide() + private let logoImageView = UIImageView().then { - $0.image = UIImage.appImage(asset: .koinLogo) + $0.image = UIImage.appImage(asset: .bcsdSymbolLogo) + $0.contentMode = .scaleAspectFit + } + + private let logoTextImageView = UIImageView().then { + $0.image = UIImage.appImage(asset: .koinTextLogo) + $0.contentMode = .scaleAspectFit } private let idTextField = UITextField().then { - $0.placeholder = "아이디(Koreatech ID/전화번호)" + $0.attributedPlaceholder = NSAttributedString( + string: "아이디(Koreatech ID/전화번호)", + attributes: [ + .foregroundColor: UIColor.appColor(.neutral400), + .font: UIFont.appFont(.pretendardRegular, size: 16) + ] + ) $0.autocapitalizationType = .none + $0.autocorrectionType = .no + $0.textContentType = .username + $0.textColor = UIColor.appColor(.neutral800) $0.font = UIFont.appFont(.pretendardRegular, size: 16) } @@ -33,18 +56,27 @@ final class LoginViewController: UIViewController { } private let idWarningLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 13) - $0.textColor = UIColor.appColor(.sub500) + $0.font = UIFont.appFont(.pretendardRegular, size: 12) + $0.textColor = UIColor.appColor(.new800) } private let passwordTextField = UITextField().then { - $0.placeholder = "비밀번호" + $0.attributedPlaceholder = NSAttributedString( + string: "비밀번호", + attributes: [ + .foregroundColor: UIColor.appColor(.neutral400), + .font: UIFont.appFont(.pretendardRegular, size: 16) + ] + ) + $0.textContentType = .password + $0.textColor = UIColor.appColor(.neutral800) $0.font = UIFont.appFont(.pretendardRegular, size: 16) $0.isSecureTextEntry = true } private let changeSecureButton = UIButton().then { button in button.setImage(UIImage.appImage(asset: .visibility), for: .normal) + button.accessibilityLabel = "비밀번호 보기" } private let separateView2 = UIView().then { @@ -52,18 +84,17 @@ final class LoginViewController: UIViewController { } private let passwordWarningLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 13) - $0.numberOfLines = 2 - $0.textColor = UIColor.appColor(.sub500) + $0.font = UIFont.appFont(.pretendardRegular, size: 12) + $0.textColor = UIColor.appColor(.new800) } private let warningImageView = UIImageView().then { - $0.image = UIImage.appImage(asset: .warningOrange) + $0.image = UIImage.appImage(asset: .warningOrange)?.withRenderingMode(.alwaysTemplate).withTintColor(.appColor(.new800)) $0.isHidden = true } private let loginButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.sub500) + $0.backgroundColor = UIColor.appColor(.new500) $0.setTitle("로그인", for: .normal) $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 15) @@ -71,46 +102,62 @@ final class LoginViewController: UIViewController { } private let registerButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) + $0.backgroundColor = UIColor.appColor(.neutral0) $0.setTitle("회원가입", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) + $0.setTitleColor(UIColor.appColor(.new500), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 15) + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.layer.borderWidth = 1 $0.layer.cornerRadius = 8 } + private let findButtonsLayoutGuide = UILayoutGuide() + private let findIdButton = UIButton().then { var configuration = UIButton.Configuration.plain() configuration.image = UIImage.appImage(asset: .findId) var text = AttributedString("아이디 찾기") - text.font = UIFont.appFont(.pretendardRegular, size: 13) + text.font = UIFont.appFont(.pretendardRegular, size: 12) configuration.attributedTitle = text - configuration.imagePadding = 1 + configuration.imagePadding = 4 + configuration.contentInsets = .zero configuration.baseForegroundColor = UIColor.appColor(.neutral500) - $0.backgroundColor = .clear $0.configuration = configuration } + private let findSeparatorLabel = UILabel().then { + $0.text = "|" + $0.textColor = .appColor(.neutral500) + $0.font = .appFont(.pretendardRegular, size: 15) + } + private let findPasswordButton = UIButton().then { var configuration = UIButton.Configuration.plain() configuration.image = UIImage.appImage(asset: .findPassword) var text = AttributedString("비밀번호 찾기") - text.font = UIFont.appFont(.pretendardRegular, size: 13) + text.font = UIFont.appFont(.pretendardRegular, size: 12) configuration.attributedTitle = text - configuration.imagePadding = 1 + configuration.imagePadding = 4 + configuration.contentInsets = .zero configuration.baseForegroundColor = UIColor.appColor(.neutral500) - $0.backgroundColor = .clear $0.configuration = configuration } + private let footerLayoutGuide = UILayoutGuide() + + private let ownerButton = UIButton().then { + $0.setTitle("사장님이신가요?", for: .normal) + $0.setTitleColor(UIColor.appColor(.new500), for: .normal) + $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 18) + } + private let copyrightLabel = UILabel().then { $0.text = "Copyright @ BCSD Lab All rights reserved." - $0.textColor = UIColor.appColor(.neutral700) + $0.textColor = UIColor.appColor(.neutral600) $0.font = UIFont.appFont(.pretendardRegular, size: 12) $0.textAlignment = .center } - private let modifyUserModalViewController = ModifyUserModalViewController() - // MARK: - Initialization init(viewModel: LoginViewModel) { self.viewModel = viewModel @@ -125,7 +172,7 @@ final class LoginViewController: UIViewController { // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() - navigationItem.title = "로그인" + title = "로그인" configureView() bind() hideKeyboardWhenTappedAround() @@ -134,34 +181,23 @@ final class LoginViewController: UIViewController { registerButton.addTarget(self, action: #selector(registerButtonTapped), for: .touchUpInside) findIdButton.addTarget(self, action: #selector(findIdButtonTapped), for: .touchUpInside) findPasswordButton.addTarget(self, action: #selector(findPasswordButtonTapped), for: .touchUpInside) + ownerButton.addTarget(self, action: #selector(ownerButtonTapped), for: .touchUpInside) idTextField.delegate = self passwordTextField.delegate = self } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - configureNavigationBar(style: .fill) + configureNavigationBar(style: .empty) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + configureNavigationBar(style: .empty + ) } private func bind() { - modifyUserModalViewController.cancelButtonPublisher.sink { [weak self] in - self?.navigationController?.popViewController(animated: true) - }.store(in: &subscriptions) - - modifyUserModalViewController.navigateButtonPublisher.sink { [weak self] in - guard let self = self else { return } - let homeViewController = makeHomeTabBarController() - - let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) - let modifyUseCase = DefaultModifyUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) - let fetchDeptListUseCase = DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())) - let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) - let checkDuplicatedNicknameUseCase = DefaultCheckDuplicatedNicknameUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) - let changeMyProfileViewController = ChangeMyProfileViewController(viewModel: ChangeMyProfileViewModel(modifyUseCase: modifyUseCase, fetchDeptListUseCase: fetchDeptListUseCase, fetchUserDataUseCase: fetchUserDataUseCase, checkDuplicatedNicknameUseCase: checkDuplicatedNicknameUseCase, logAnalyticsEventUseCase: logAnalyticsEventUseCase), userType: .student) - navigationController?.setViewControllers([homeViewController, changeMyProfileViewController], animated: true) - - }.store(in: &subscriptions) - let outputSubject = viewModel.transform(with: inputSubject.eraseToAnyPublisher()) outputSubject.receive(on: DispatchQueue.main).sink { [weak self] output in switch output { @@ -172,20 +208,63 @@ final class LoginViewController: UIViewController { case .loginSuccess: self?.navigationController?.popViewController(animated: true) self?.inputSubject.send(.logEvent(EventParameter.EventLabel.User.login, .click, "로그인 완료")) - self?.completion?() case .showForceModal: self?.navigationController?.setViewControllers([ForceModifyUserViewController()], animated: true) case .showModifyModal: - self?.present(self?.modifyUserModalViewController ?? UIViewController(), animated: true) + self?.presentModifyUserModal() } }.store(in: &subscriptions) } } extension LoginViewController { + private func presentModifyUserModal() { + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "아직 입력되지 않은 정보가 있어요.", + mainTitleStyle: .init( + textColor: .neutral800, + font: .pretendardMedium, + fontSize: 18 + ), + subTitleText: "필수 정보를 입력하시면 더 많은 기능을 이용하실 수 있어요.\n지금 입력하시겠어요?", + subTitleStyle: .init( + textColor: .neutral500, + font: .pretendardRegular, + fontSize: 12 + ) + ), + button: .buttons( + leftButtonTitle: "나중에 하기", + leftButtonAction: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }, + rightButtonTitle: "지금 입력하기", + rightButtonAction: { [weak self] in + self?.navigateToChangeMyProfile() + } + ), + layout: .init(width: 342) + )) + present(modalViewController, animated: true) + } + + private func navigateToChangeMyProfile() { + let homeViewController = makeHomeTabBarController() + let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) + let modifyUseCase = DefaultModifyUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) + let fetchDeptListUseCase = DefaultFetchDeptListUseCase(timetableRepository: DefaultTimetableRepository(service: DefaultTimetableService())) + let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) + let checkDuplicatedNicknameUseCase = DefaultCheckDuplicatedNicknameUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) + let changeMyProfileViewController = ChangeMyProfileViewController(viewModel: ChangeMyProfileViewModel(modifyUseCase: modifyUseCase, fetchDeptListUseCase: fetchDeptListUseCase, fetchUserDataUseCase: fetchUserDataUseCase, checkDuplicatedNicknameUseCase: checkDuplicatedNicknameUseCase, logAnalyticsEventUseCase: logAnalyticsEventUseCase), userType: .student) + navigationController?.setViewControllers([homeViewController, changeMyProfileViewController], animated: true) + } + @objc private func changeSecureButtonTapped() { passwordTextField.isSecureTextEntry.toggle() changeSecureButton.setImage(passwordTextField.isSecureTextEntry ? UIImage.appImage(asset: .visibility) : UIImage.appImage(asset: .visibilityNon), for: .normal) + changeSecureButton.accessibilityLabel = passwordTextField.isSecureTextEntry ? "비밀번호 보기" : "비밀번호 숨기기" } @objc private func findIdButtonTapped() { @@ -199,6 +278,11 @@ extension LoginViewController { navigationController?.pushViewController(findPasswordViewController, animated: true) inputSubject.send(.logEvent(EventParameter.EventLabel.User.login, .click, "비밀번호 찾기")) } + + @objc private func ownerButtonTapped() { + guard let url = URL(string: "https://owner.koreatech.in") else { return } + present(SFSafariViewController(url: url), animated: true) + } @objc func loginButtonTapped() { warningImageView.isHidden = true @@ -249,27 +333,57 @@ extension LoginViewController { extension LoginViewController { private func setUpLayOuts() { - [logoImageView, idTextField, separateView1, idWarningLabel, passwordTextField, changeSecureButton, separateView2, warningImageView, passwordWarningLabel, loginButton, registerButton, findIdButton, findPasswordButton, copyrightLabel].forEach { + [logoImageView, logoTextImageView, + idTextField, separateView1, passwordTextField, changeSecureButton, separateView2, + warningImageView, idWarningLabel, passwordWarningLabel, + loginButton, registerButton, + findIdButton, findSeparatorLabel, findPasswordButton, + ownerButton, copyrightLabel].forEach { + contentView.addSubview($0) + } + + [contentTopPaddingLayoutGuide, contentLayoutGuide, contentBottomPaddingLayoutGuide, findButtonsLayoutGuide, footerLayoutGuide].forEach { + contentView.addLayoutGuide($0) + } + + [contentView].forEach { + scrollView.addSubview($0) + } + + [scrollView].forEach { view.addSubview($0) } } + private func setUpConstraints() { - logoImageView.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(110) - make.leading.equalTo(view.snp.leading).offset(40) - make.height.equalTo(60) - make.width.equalTo(107) + scrollView.snp.makeConstraints { + $0.leading.trailing.top.equalToSuperview() + $0.bottom.equalTo(view.keyboardLayoutGuide.snp.top) } - idTextField.snp.makeConstraints { make in - make.top.equalTo(logoImageView.snp.bottom).offset(32) - make.leading.equalTo(view.snp.leading).offset(48) - make.trailing.equalTo(view.snp.trailing).offset(-48) - make.height.equalTo(40) + contentView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.width.equalTo(scrollView) + $0.height.greaterThanOrEqualTo(view.safeAreaLayoutGuide) + } + + logoImageView.snp.makeConstraints { + $0.top.centerX.equalTo(contentLayoutGuide) + $0.height.equalTo(66) + } + logoTextImageView.snp.makeConstraints { + $0.top.equalTo(logoImageView.snp.bottom).offset(9) + $0.centerX.equalTo(contentLayoutGuide) + $0.width.equalTo(102) + $0.height.equalTo(41) + } + idTextField.snp.makeConstraints { + $0.top.equalTo(logoTextImageView.snp.bottom).offset(52) + $0.leading.trailing.equalTo(contentLayoutGuide).inset(48) + $0.height.equalTo(40) } separateView1.snp.makeConstraints { make in make.top.equalTo(idTextField.snp.bottom) - make.leading.equalTo(idTextField.snp.leading) - make.trailing.equalTo(idTextField.snp.trailing) + make.leading.trailing.equalTo(contentLayoutGuide).inset(48) make.height.equalTo(1) } idWarningLabel.snp.makeConstraints { make in @@ -278,20 +392,18 @@ extension LoginViewController { make.height.equalTo(20) } passwordTextField.snp.makeConstraints { make in - make.top.equalTo(idTextField.snp.bottom).offset(16) - make.leading.equalTo(idTextField.snp.leading) - make.trailing.equalTo(idTextField.snp.trailing) + make.top.equalTo(idTextField.snp.bottom).offset(24) + make.leading.trailing.equalTo(contentLayoutGuide).inset(48) make.height.equalTo(40) } changeSecureButton.snp.makeConstraints { make in make.centerY.equalTo(passwordTextField.snp.centerY) - make.trailing.equalTo(passwordTextField.snp.trailing) - make.width.height.equalTo(20) + make.trailing.equalTo(passwordTextField.snp.trailing).offset(12) + make.width.height.equalTo(44) } separateView2.snp.makeConstraints { make in make.top.equalTo(passwordTextField.snp.bottom) - make.leading.equalTo(passwordTextField.snp.leading) - make.trailing.equalTo(passwordTextField.snp.trailing) + make.leading.trailing.equalTo(contentLayoutGuide).inset(48) make.height.equalTo(1) } warningImageView.snp.makeConstraints { make in @@ -300,45 +412,74 @@ extension LoginViewController { make.width.height.equalTo(16) } passwordWarningLabel.snp.makeConstraints { make in - make.top.equalTo(separateView2.snp.bottom) + make.top.equalTo(separateView2.snp.bottom).offset(8) make.leading.equalTo(warningImageView.snp.trailing).offset(4) make.height.greaterThanOrEqualTo(20) } loginButton.snp.makeConstraints { make in make.top.equalTo(separateView2.snp.bottom).offset(48) - make.leading.equalTo(separateView2.snp.leading) - make.trailing.equalTo(separateView2.snp.trailing) + make.leading.trailing.equalTo(contentLayoutGuide).inset(48) make.height.equalTo(44) } registerButton.snp.makeConstraints { make in make.top.equalTo(loginButton.snp.bottom).offset(24) - make.leading.equalTo(loginButton.snp.leading) - make.trailing.equalTo(loginButton.snp.trailing) + make.leading.trailing.equalTo(contentLayoutGuide).inset(48) make.height.equalTo(44) } - findIdButton.snp.makeConstraints { make in - make.top.equalTo(registerButton.snp.bottom).offset(32) - make.trailing.equalTo(view.snp.centerX).offset(-5) - make.width.greaterThanOrEqualTo(84) - make.height.equalTo(20) + + findIdButton.snp.makeConstraints { + $0.leading.equalTo(findButtonsLayoutGuide) + $0.centerY.equalTo(findButtonsLayoutGuide) } - findPasswordButton.snp.makeConstraints { make in - make.top.equalTo(findIdButton.snp.top) - make.leading.equalTo(view.snp.centerX) - make.width.greaterThanOrEqualTo(100) - make.height.equalTo(20) + findSeparatorLabel.snp.makeConstraints { + $0.top.bottom.equalTo(findButtonsLayoutGuide) + $0.leading.equalTo(findIdButton.snp.trailing).offset(10) + } + findPasswordButton.snp.makeConstraints { + $0.leading.equalTo(findSeparatorLabel.snp.trailing).offset(10) + $0.trailing.equalTo(findButtonsLayoutGuide) + $0.centerY.equalTo(findButtonsLayoutGuide) + } + findButtonsLayoutGuide.snp.makeConstraints { + $0.centerX.bottom.equalTo(contentLayoutGuide) + $0.top.equalTo(registerButton.snp.bottom).offset(32) + $0.height.equalTo(22) + } + + copyrightLabel.snp.makeConstraints { + $0.bottom.centerX.equalTo(footerLayoutGuide) + $0.height.equalTo(18) + } + ownerButton.snp.makeConstraints { + $0.top.centerX.equalTo(footerLayoutGuide) + $0.bottom.equalTo(copyrightLabel.snp.top).offset(-21) + $0.height.equalTo(29) + } + + contentTopPaddingLayoutGuide.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(contentBottomPaddingLayoutGuide.snp.height).multipliedBy(2.0/3.0) + } + contentLayoutGuide.snp.makeConstraints { + $0.top.equalTo(contentTopPaddingLayoutGuide.snp.bottom) + $0.bottom.equalTo(contentBottomPaddingLayoutGuide.snp.top) + $0.leading.trailing.equalToSuperview() + } + contentBottomPaddingLayoutGuide.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(footerLayoutGuide.snp.top) + $0.height.greaterThanOrEqualTo(80) } - copyrightLabel.snp.makeConstraints { make in - make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(-32) - make.centerX.equalToSuperview() - make.height.equalTo(18) + footerLayoutGuide.snp.makeConstraints { + $0.bottom.equalToSuperview().offset(0.5 < view.safeAreaInsets.bottom ? 0 : -32) + $0.leading.trailing.equalToSuperview() } } private func configureView() { setUpLayOuts() setUpConstraints() - self.view.backgroundColor = .systemBackground + view.backgroundColor = UIColor.appColor(.neutral0) } } @@ -410,8 +551,12 @@ extension LoginViewController { } private func makeCategoryHostingController() -> UIViewController { + let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) - let categoryRootView = CategoryView(viewModel: CategoryViewModel(logAnalyticsEventUseCase: logAnalyticsEventUseCase)) + let categoryRootView = CategoryView( + viewModel: CategoryViewModel( + checkLoginUseCase: checkLoginUseCase, + logAnalyticsEventUseCase: logAnalyticsEventUseCase)) return CategoryHostingController(rootView: categoryRootView) } diff --git a/Koin/Presentation/Login/Login/ModifyUserModalViewController.swift b/Koin/Presentation/Login/Login/ModifyUserModalViewController.swift deleted file mode 100644 index 8d6cd601..00000000 --- a/Koin/Presentation/Login/Login/ModifyUserModalViewController.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// ModifyUserModalViewController.swift -// koin -// -// Created by 김나훈 on 7/14/25. -// - -import Combine -import UIKit - -final class ModifyUserModalViewController: UIViewController { - - let cancelButtonPublisher = PassthroughSubject() - let navigateButtonPublisher = PassthroughSubject() - - private let messageLabel = UILabel().then { - $0.text = "아직 입력되지 않은 정보가 있어요." - $0.font = UIFont.appFont(.pretendardMedium, size: 18) - $0.textColor = .black - } - - private let subMessageLabel = UILabel().then { - $0.text = "필수 정보를 입력하시면 더 많은 기능을 이용하실 수 있어요.\n지금 입력하시겠어요?" - $0.font = UIFont.appFont(.pretendardRegular, size: 12) - $0.numberOfLines = 2 - $0.textAlignment = .center - $0.textColor = .gray - } - - private let cancelButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("나중에 하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let navigateButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitle("지금 입력하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 16 - view.layer.masksToBounds = true - return view - }() - - init() { - super.init(nibName: nil, bundle: nil) - self.modalPresentationStyle = .overFullScreen - self.modalTransitionStyle = .crossDissolve - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) - navigateButton.addTarget(self, action: #selector(navigateButtonTapped), for: .touchUpInside) - } - @objc private func cancelButtonTapped() { - cancelButtonPublisher.send() - dismiss(animated: true) - } - @objc private func navigateButtonTapped() { - navigateButtonPublisher.send() - dismiss(animated: true) - } - -} - -extension ModifyUserModalViewController { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, subMessageLabel, cancelButton, navigateButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { - $0.centerX.equalTo(view.snp.centerX) - $0.centerY.equalTo(view.snp.centerY) - $0.horizontalEdges.equalToSuperview().inset(24) - $0.height.equalTo(232) - } - messageLabel.snp.makeConstraints { - $0.top.equalToSuperview().offset(42.5) - $0.centerX.equalToSuperview() - } - subMessageLabel.snp.makeConstraints { - $0.top.equalTo(messageLabel.snp.bottom).offset(12) - $0.centerX.equalToSuperview() - } - cancelButton.snp.makeConstraints { - $0.leading.equalToSuperview().offset(32) - $0.bottom.equalToSuperview().offset(-42.5) - $0.trailing.equalTo(view.snp.centerX).offset(-4) - $0.height.equalTo(48) - } - navigateButton.snp.makeConstraints { - $0.leading.equalTo(view.snp.centerX).offset(4) - $0.bottom.equalToSuperview().offset(-42.5) - $0.trailing.equalToSuperview().offset(-32) - $0.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } -} diff --git a/Koin/Presentation/Login/Register/RegisterFormViewModel.swift b/Koin/Presentation/Login/Register/RegisterFormViewModel.swift index de00b3d6..4e27ed98 100644 --- a/Koin/Presentation/Login/Register/RegisterFormViewModel.swift +++ b/Koin/Presentation/Login/Register/RegisterFormViewModel.swift @@ -106,7 +106,7 @@ extension RegisterFormViewModel { private func checkDuplicatedPhoneNumber(phone: String) { checkDuplicatedPhoneNumberUseCase.execute(phone: phone).sink { [weak self] completion in if case let .failure(error) = completion { - self?.outputSubject.send(.showHttpResult(error.message, .sub500)) + self?.outputSubject.send(.showHttpResult(error.message, .new600)) } } receiveValue: { [weak self] (_: Void) in self?.outputSubject.send(.changeSendVerificationButtonStatus) @@ -169,7 +169,7 @@ extension RegisterFormViewModel { private func checkDuplicatedNickname(nickname: String) { checkDuplicatedNicknameUseCase.execute(nickname: nickname).sink { [weak self] completion in if case let .failure(error) = completion { - self?.outputSubject.send(.showHttpResult(error.message, .danger700)) + self?.outputSubject.send(.showNicknameHttpResult(error.message, .new600)) } } receiveValue: { [weak self] _ in self?.outputSubject.send(.changeCheckButtonStatus) diff --git a/Koin/Presentation/Login/Register/ViewControllers/AgreementFormViewController.swift b/Koin/Presentation/Login/Register/ViewControllers/AgreementFormViewController.swift index 642741e5..6931d1c0 100644 --- a/Koin/Presentation/Login/Register/ViewControllers/AgreementFormViewController.swift +++ b/Koin/Presentation/Login/Register/ViewControllers/AgreementFormViewController.swift @@ -26,19 +26,19 @@ final class AgreementFormViewController: UIViewController { private let stepTextLabel = UILabel().then { $0.text = "1. 약관 동의" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let stepLabel = UILabel().then { $0.text = "1 / 4" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral200) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 0.25 @@ -65,7 +65,7 @@ final class AgreementFormViewController: UIViewController { config.image = resizedImage config.imagePlacement = .leading config.imagePadding = 8 - config.baseForegroundColor = UIColor.appColor(.primary500) + config.baseForegroundColor = UIColor.appColor(.new500) config.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0) var attrTitle = AttributedString("모두 동의합니다.") @@ -79,7 +79,7 @@ final class AgreementFormViewController: UIViewController { $0.configurationUpdateHandler = { button in var updatedConfig = button.configuration - updatedConfig?.baseForegroundColor = UIColor.appColor(.primary500) + updatedConfig?.baseForegroundColor = UIColor.appColor(.new500) updatedConfig?.background.backgroundColor = UIColor.appColor(.neutral100) button.configuration = updatedConfig } @@ -150,12 +150,14 @@ extension AgreementFormViewController { let requiredChecked = agreementItems[0].checkButton.isSelected && agreementItems[1].checkButton.isSelected nextButton.isEnabled = requiredChecked - nextButton.backgroundColor = requiredChecked ? UIColor.appColor(.primary500) : UIColor.appColor(.neutral300) + nextButton.backgroundColor = requiredChecked ? UIColor.appColor(.new500) : UIColor.appColor(.neutral300) nextButton.setTitleColor(requiredChecked ? .white : UIColor.appColor(.neutral600), for: .normal) } private func updateCheckboxImage(checkbox: UIButton, isSelected: Bool) { - let original = isSelected ? UIImage.appImage(asset: .checkFilledCircle) : UIImage.appImage(asset: .checkEmptyCircle) + let original = isSelected + ? UIImage.appImage(asset: .checkFilledCircle)?.withTintColor(.appColor(.new500), renderingMode: .alwaysTemplate) + : UIImage.appImage(asset: .checkEmptyCircle) let resized = original?.resize(to: CGSize(width: 16, height: 16)) checkbox.setImage(resized, for: .normal) } diff --git a/Koin/Presentation/Login/Register/ViewControllers/CertificationFormViewController.swift b/Koin/Presentation/Login/Register/ViewControllers/CertificationFormViewController.swift index 684aaa85..af3e3ffd 100644 --- a/Koin/Presentation/Login/Register/ViewControllers/CertificationFormViewController.swift +++ b/Koin/Presentation/Login/Register/ViewControllers/CertificationFormViewController.swift @@ -27,19 +27,19 @@ final class CertificationFormViewController: UIViewController { private let stepTextLabel = UILabel().then { $0.text = "2. 본인 인증" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let stepLabel = UILabel().then { $0.text = "2 / 4" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral200) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 0.5 @@ -70,16 +70,16 @@ final class CertificationFormViewController: UIViewController { ) private let nameHelpLabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "올바른 양식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.sub500)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "올바른 양식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.isHidden = true } private let femaleButton = UIButton().then { - $0.applyRadioStyle(title: "여성", font: .appFont(.pretendardRegular, size: 16), image: .appImage(asset: .circlePrimary500), foregroundColor: .black) + $0.applyRadioStyle(title: "여성", font: .appFont(.pretendardRegular, size: 16), image: .appImage(asset: .circlePrimary500)?.withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal), foregroundColor: .black) } private let maleButton = UIButton().then { - $0.applyRadioStyle(title: "남성", font: .appFont(.pretendardRegular, size: 16), image: .appImage(asset: .circlePrimary500), foregroundColor: .black) + $0.applyRadioStyle(title: "남성", font: .appFont(.pretendardRegular, size: 16), image: .appImage(asset: .circlePrimary500)?.withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal), foregroundColor: .black) } private let phoneNumberLabel = UILabel().then { @@ -94,13 +94,14 @@ final class CertificationFormViewController: UIViewController { placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14) ).then { + $0.keyboardType = .numberPad $0.isHidden = true } private let sendVerificationButton = StatefulButton( title: "인증번호 발송", font: .appFont(.pretendardRegular, size: 10), - enabledColor: .appColor(.primary500), + enabledColor: .appColor(.new500), disabledColor: .appColor(.neutral300), cornerRadius: 4 ).then { @@ -109,14 +110,14 @@ final class CertificationFormViewController: UIViewController { } private let phoneNumberReponseLabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.danger700)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.numberOfLines = 2 $0.isHidden = true } let goToLoginButton = UIButton().then { $0.setTitle("로그인 하기", for: .normal) - $0.setTitleColor(.appColor(.primary500), for: .normal) + $0.setTitleColor(.appColor(.new500), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 12) $0.isHidden = true } @@ -130,7 +131,7 @@ final class CertificationFormViewController: UIViewController { private let contactButton = UIButton().then { $0.setTitle("문의하기", for: .normal) - $0.setTitleColor(.appColor(.primary500), for: .normal) + $0.setTitleColor(.appColor(.new500), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 12) $0.isHidden = true } @@ -140,6 +141,7 @@ final class CertificationFormViewController: UIViewController { placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14) ).then { + $0.keyboardType = .numberPad $0.isHidden = true } @@ -154,7 +156,7 @@ final class CertificationFormViewController: UIViewController { private let verificationButton = StatefulButton( title: "인증번호 확인", font: .appFont(.pretendardRegular, size: 10), - enabledColor: .appColor(.primary500), + enabledColor: .appColor(.new500), disabledColor: .appColor(.neutral300), cornerRadius: 4 ).then { @@ -163,7 +165,7 @@ final class CertificationFormViewController: UIViewController { } private let verificationHelpLabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.danger700)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.isHidden = true } @@ -238,7 +240,7 @@ final class CertificationFormViewController: UIViewController { self?.viewModel.tempPhoneNumber = self?.phoneNumberTextField.text self?.viewModel.tempGender = self?.femaleButton.configuration?.image == UIImage.appImage(asset: .circleCheckedPrimary500) ? "1" : "0" self?.nextButton.isEnabled = true - self?.nextButton.backgroundColor = UIColor.appColor(.primary500) + self?.nextButton.backgroundColor = UIColor.appColor(.new500) self?.nextButton.setTitleColor(.white, for: .normal) let customSessionId = CustomSessionManager.getOrCreateSessionId(duration: .fifteenMinutes, eventName: "sign_up", loginStatus: 0, platform: "iOS") self?.inputSubject.send(.logEventWithSessionId(EventParameter.EventLabel.User.identityVerification, .click, "인증완료", customSessionId)) @@ -342,8 +344,8 @@ extension CertificationFormViewController { var femaleConfig = femaleButton.configuration var maleConfig = maleButton.configuration - femaleConfig?.image = UIImage.appImage(asset: isFemale ? .circleCheckedPrimary500 : .circlePrimary500) - maleConfig?.image = UIImage.appImage(asset: isFemale ? .circlePrimary500 : .circleCheckedPrimary500) + femaleConfig?.image = UIImage.appImage(asset: isFemale ? .circleCheckedPrimary500 : .circlePrimary500)?.withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal) + maleConfig?.image = UIImage.appImage(asset: isFemale ? .circlePrimary500 : .circleCheckedPrimary500)?.withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal) femaleButton.configuration = femaleConfig maleButton.configuration = maleConfig @@ -352,7 +354,8 @@ extension CertificationFormViewController { private func updatePhoneNumberSectionVisibility() { let nameCount = nameTextField.text?.count ?? 0 let isNameValid = (2...5).contains(nameCount) - let isGenderSelected = (femaleButton.configuration?.image == UIImage.appImage(asset: .circleCheckedPrimary500)) || (maleButton.configuration?.image == UIImage.appImage(asset: .circleCheckedPrimary500)) + let isGenderSelected = (femaleButton.configuration?.image == UIImage.appImage(asset: .circleCheckedPrimary500)?.withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal)) + || (maleButton.configuration?.image == UIImage.appImage(asset: .circleCheckedPrimary500)?.withTintColor(.appColor(.new500), renderingMode: .alwaysOriginal)) let shouldShowPhoneFields = isNameValid && isGenderSelected @@ -401,10 +404,10 @@ extension CertificationFormViewController { private func showVerificationHelpResult(_ message: String, _ color: ColorAsset) { verificationHelpLabel.isHidden = false verificationHelpLabel.setImageText( - image: UIImage.appImage(asset: .warningOrange), + image: UIImage.appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: message, font: UIFont.appFont(.pretendardRegular, size: 12), - textColor: UIColor.appColor(color) + textColor: UIColor.appColor(.new600) ) } @@ -419,13 +422,19 @@ extension CertificationFormViewController { private func showHttpResult(_ message: String, _ color: ColorAsset) { phoneNumberReponseLabel.isHidden = false - if message == "이미 존재하는 전화번호입니다." { + if message.contains("이미 존재") { phoneNumberReponseLabel.setImageText(image: .appImage(asset: .warningRed), text: message, font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.danger600)) [goToLoginButton, phoneNotFoundLabel, contactButton].forEach { $0.isHidden = false } + } else if message.contains("24시간 이후 재시도") { + phoneNumberReponseLabel.setImageText(image: .appImage(asset: .warningRed), text: message, font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.danger600)) + [goToLoginButton, phoneNotFoundLabel, contactButton].forEach { + $0.isHidden = true + } + sendVerificationButton.updateState(isEnabled: false) } else { - phoneNumberReponseLabel.setImageText(image: .appImage(asset: .warningOrange), text: message, font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(color)) + phoneNumberReponseLabel.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: message, font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) [goToLoginButton, phoneNotFoundLabel, contactButton].forEach { $0.isHidden = true } @@ -474,7 +483,7 @@ extension CertificationFormViewController { self.timer?.invalidate() self.timer = nil self.verificationHelpLabel.isHidden = false - self.verificationHelpLabel.setImageText(image: .appImage(asset: .warningOrange), text: "유효시간이 지났습니다. 인증번호를 재발송 해주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.sub500)) + self.verificationHelpLabel.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "유효시간이 지났습니다. 인증번호를 재발송 해주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) } } } diff --git a/Koin/Presentation/Login/Register/ViewControllers/EnterFormViewController.swift b/Koin/Presentation/Login/Register/ViewControllers/EnterFormViewController.swift index 9e652f3b..a58a8de4 100644 --- a/Koin/Presentation/Login/Register/ViewControllers/EnterFormViewController.swift +++ b/Koin/Presentation/Login/Register/ViewControllers/EnterFormViewController.swift @@ -25,19 +25,19 @@ final class EnterFormViewController: UIViewController { private let stepTextLabel = UILabel().then { $0.text = "4. 정보 입력" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let stepLabel = UILabel().then { $0.text = "4 / 4" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral200) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 1 @@ -65,12 +65,15 @@ final class EnterFormViewController: UIViewController { placeholder: "5~13자리로 입력해 주세요.", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14) - ) + ).then { + $0.autocorrectionType = .no + $0.textContentType = .oneTimeCode + } private let checkIdDuplicateButton = StatefulButton( title: "중복 확인", font: .appFont(.pretendardRegular, size: 10), - enabledColor: .appColor(.primary500), + enabledColor: .appColor(.new500), disabledColor: .appColor(.neutral300), cornerRadius: 4 ).then { @@ -99,10 +102,12 @@ final class EnterFormViewController: UIViewController { font: UIFont.appFont(.pretendardRegular, size: 13) ).then { $0.isSecureTextEntry = true + $0.autocorrectionType = .no + $0.textContentType = .oneTimeCode } private let passwordInfoLabel: UILabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "올바른 비밀번호 양식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.sub500)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "올바른 비밀번호 양식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.isHidden = true } @@ -112,6 +117,8 @@ final class EnterFormViewController: UIViewController { font: UIFont.appFont(.pretendardRegular, size: 13) ).then { $0.isSecureTextEntry = true + $0.autocorrectionType = .no + $0.textContentType = .oneTimeCode $0.isHidden = true } @@ -159,11 +166,12 @@ final class EnterFormViewController: UIViewController { placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14) ).then { + $0.keyboardType = .numberPad $0.isHidden = true } private let studentIdWarningLabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "올바른 학번 양식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.sub500)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "올바른 학번 양식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.isHidden = true } @@ -178,7 +186,7 @@ final class EnterFormViewController: UIViewController { private let nicknameDuplicateButton = StatefulButton( title: "중복 확인", font: .appFont(.pretendardRegular, size: 10), - enabledColor: .appColor(.primary500), + enabledColor: .appColor(.new500), disabledColor: .appColor(.neutral300), cornerRadius: 4 ).then { @@ -187,7 +195,7 @@ final class EnterFormViewController: UIViewController { } private let nicknameResponseLabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "중복된 닉네임입니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.sub500)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "이미 존재하는 닉네임입니다..", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.isHidden = true } @@ -215,7 +223,7 @@ final class EnterFormViewController: UIViewController { } private let generalEmailResponseLabel = UILabel().then { - $0.setImageText(image: .appImage(asset: .warningOrange), text: "올바른 이메일 형식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.sub500)) + $0.setImageText(image: .appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: "올바른 이메일 형식이 아닙니다. 다시 입력해 주세요.", font: .appFont(.pretendardRegular, size: 12), textColor: .appColor(.new600)) $0.isHidden = true } @@ -266,10 +274,10 @@ final class EnterFormViewController: UIViewController { guard !message.isEmpty else { return } self?.checkIdResponseLabel.isHidden = false self?.checkIdResponseLabel.setImageText( - image: UIImage.appImage(asset: .warningOrange), + image: UIImage.appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: message, font: UIFont.appFont(.pretendardRegular, size: 12), - textColor: .appColor(.sub500) + textColor: .appColor(.new600) ) case .successCheckDuplicatedId: self?.checkIdResponseLabel.isHidden = false @@ -285,13 +293,13 @@ final class EnterFormViewController: UIViewController { case let .showDeptDropDownList(deptList): self?.setUpDropDown(dropDown: strongSelf.deptDropDown, button: strongSelf.departmentDropdownButton, dataSource: deptList) case let .showNicknameHttpResult(message, color): - self?.nicknameResponseLabel.isHidden = false self?.nicknameResponseLabel.setImageText( - image: UIImage.appImage(asset: .warningOrange), + image: UIImage.appImage(asset: .warningOrange)?.withTintColor(.appColor(.new600), renderingMode: .alwaysOriginal), text: message, font: UIFont.appFont(.pretendardRegular, size: 12), - textColor: .appColor(.sub500) + textColor: .appColor(.new600) ) + self?.nicknameResponseLabel.isHidden = false case .changeCheckButtonStatus: self?.nicknameDuplicateButton.updateState(isEnabled: false) self?.nicknameResponseLabel.setImageText( @@ -399,10 +407,13 @@ extension EnterFormViewController { @objc private func keyboardWillShow(_ notification: Notification) { guard let userInfo = notification.userInfo, - let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + let keyboardFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { + return + } + let bottomInset = keyboardFrame.height - (view.frame.height - nextButton.frame.minY) - scrollView.contentInset.bottom = keyboardFrame.height - scrollView.verticalScrollIndicatorInsets.bottom = keyboardFrame.height + scrollView.contentInset.bottom = bottomInset + scrollView.verticalScrollIndicatorInsets.bottom = bottomInset } @objc private func keyboardWillHide(_ notification: Notification) { @@ -509,7 +520,7 @@ extension EnterFormViewController { if isValid { nextButton.isEnabled = true - nextButton.backgroundColor = UIColor.appColor(.primary500) + nextButton.backgroundColor = UIColor.appColor(.new500) nextButton.setTitleColor(.white, for: .normal) } else { nextButton.isEnabled = false @@ -690,7 +701,7 @@ extension EnterFormViewController { } checkIdResponseLabel.snp.makeConstraints { - $0.top.equalTo(idTextField.snp.bottom).offset(8) + $0.top.equalTo(idTextField.snp.bottom) $0.leading.equalTo(idTextField.snp.leading).offset(4) $0.height.equalTo(20) } @@ -757,7 +768,7 @@ extension EnterFormViewController { } nicknameTextField.snp.makeConstraints { - $0.top.equalTo(studentIdTextField.snp.bottom).offset(8) + $0.top.equalTo(studentIdTextField.snp.bottom).offset(20) $0.leading.equalTo(departmentDropdownButton.snp.leading) $0.trailing.equalTo(nicknameDuplicateButton.snp.leading).offset(-16) $0.height.equalTo(40) @@ -777,7 +788,7 @@ extension EnterFormViewController { } studentEmailTextField.snp.makeConstraints { - $0.top.equalTo(nicknameTextField.snp.bottom).offset(8) + $0.top.equalTo(nicknameTextField.snp.bottom).offset(20) $0.leading.equalTo(departmentDropdownButton.snp.leading) $0.trailing.equalToSuperview().offset(-126) $0.height.equalTo(40) @@ -844,7 +855,7 @@ extension EnterFormViewController { setUpGeneralConstraints() nextButton.isEnabled = true - nextButton.backgroundColor = UIColor.appColor(.primary500) + nextButton.backgroundColor = UIColor.appColor(.new500) nextButton.setTitleColor(.white, for: .normal) } diff --git a/Koin/Presentation/Login/Register/ViewControllers/RegisterCompletionViewController.swift b/Koin/Presentation/Login/Register/ViewControllers/RegisterCompletionViewController.swift index a1d8f429..2ead5772 100644 --- a/Koin/Presentation/Login/Register/ViewControllers/RegisterCompletionViewController.swift +++ b/Koin/Presentation/Login/Register/ViewControllers/RegisterCompletionViewController.swift @@ -11,8 +11,18 @@ import SnapKit final class RegisterCompletionViewController: UIViewController { // MARK: - UI Components - private let koinLogoImageView = UIImageView().then { - $0.image = UIImage.appImage(asset: .koinLogo) + private let contentTopLayoutGuide = UILayoutGuide() + private let contentLayoutGuide = UILayoutGuide() + private let contentBottomLayoutGuide = UILayoutGuide() + + private let logoImageView = UIImageView().then { + $0.image = UIImage.appImage(asset: .bcsdSymbolLogo) + $0.contentMode = .scaleAspectFit + } + + private let logoTextImageView = UIImageView().then { + $0.image = UIImage.appImage(asset: .koinTextLogo) + $0.contentMode = .scaleAspectFit } private let registerCompletionLabel = UILabel().then { @@ -24,7 +34,7 @@ final class RegisterCompletionViewController: UIViewController { private let loginButton = UIButton().then { $0.setTitle("로그인 바로가기", for: .normal) $0.layer.cornerRadius = 8 - $0.backgroundColor = UIColor.appColor(.sub500) + $0.backgroundColor = .appColor(.new500) $0.setTitleColor(UIColor(.white), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) } @@ -32,9 +42,11 @@ final class RegisterCompletionViewController: UIViewController { private let homeButton = UIButton().then { $0.setTitle("홈화면 바로가기", for: .normal) $0.layer.cornerRadius = 8 - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitleColor(UIColor(.white), for: .normal) + $0.backgroundColor = .appColor(.neutral0) + $0.setTitleColor(.appColor(.new500), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.layer.borderWidth = 1 } // MARK: - Life Cycle @@ -71,36 +83,61 @@ extension RegisterCompletionViewController { extension RegisterCompletionViewController { private func setUpLayout() { - [koinLogoImageView, registerCompletionLabel, loginButton, homeButton].forEach { + [logoImageView, logoTextImageView, registerCompletionLabel, loginButton, homeButton].forEach { view.addSubview($0) } + + [contentTopLayoutGuide, contentLayoutGuide, contentBottomLayoutGuide].forEach { + view.addLayoutGuide($0) + } } private func setUpConstraints() { - koinLogoImageView.snp.makeConstraints { - $0.bottom.equalTo(registerCompletionLabel.snp.top).offset(-24) - $0.centerX.equalToSuperview() - $0.height.greaterThanOrEqualTo(56) - $0.width.greaterThanOrEqualTo(96) + logoImageView.snp.makeConstraints { + $0.top.centerX.equalTo(contentLayoutGuide) + $0.height.equalTo(66) + } + + logoTextImageView.snp.makeConstraints { + $0.top.equalTo(logoImageView.snp.bottom).offset(9) + $0.centerX.equalTo(contentLayoutGuide) + $0.width.equalTo(102) + $0.height.equalTo(41) } registerCompletionLabel.snp.makeConstraints { + $0.top.equalTo(logoTextImageView.snp.bottom).offset(24) $0.bottom.equalTo(loginButton.snp.top).offset(-56) - $0.centerX.equalToSuperview() + $0.centerX.equalTo(contentLayoutGuide) $0.height.equalTo(29) } loginButton.snp.makeConstraints { - $0.centerY.equalToSuperview() - $0.horizontalEdges.equalToSuperview().inset(24) + $0.horizontalEdges.equalTo(contentLayoutGuide).inset(48) $0.height.equalTo(48) } homeButton.snp.makeConstraints { $0.top.equalTo(loginButton.snp.bottom).offset(24) - $0.horizontalEdges.equalToSuperview().inset(24) + $0.horizontalEdges.equalTo(contentLayoutGuide).inset(48) + $0.bottom.equalTo(contentLayoutGuide) $0.height.equalTo(48) } + + contentTopLayoutGuide.snp.makeConstraints { + $0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + $0.bottom.equalTo(contentLayoutGuide.snp.top) + $0.height.equalTo(contentBottomLayoutGuide.snp.height).multipliedBy(2.0/3.0) + } + + contentLayoutGuide.snp.makeConstraints { + $0.leading.trailing.equalTo(view.safeAreaLayoutGuide) + $0.bottom.equalTo(contentBottomLayoutGuide.snp.top) + } + + contentBottomLayoutGuide.snp.makeConstraints { + $0.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) + } } private func configureView() { diff --git a/Koin/Presentation/Login/Register/ViewControllers/SelectTypeFormViewController.swift b/Koin/Presentation/Login/Register/ViewControllers/SelectTypeFormViewController.swift index 196fd36f..2fc12b98 100644 --- a/Koin/Presentation/Login/Register/ViewControllers/SelectTypeFormViewController.swift +++ b/Koin/Presentation/Login/Register/ViewControllers/SelectTypeFormViewController.swift @@ -24,19 +24,19 @@ final class SelectTypeFormViewController: UIViewController { // MARK: - UI Components private let stepTextLabel = UILabel().then { $0.text = "3. 회원 유형 선택" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let stepLabel = UILabel().then { $0.text = "3 / 4" - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) $0.font = UIFont.appFont(.pretendardMedium, size: 16) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral200) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 0.75 @@ -47,11 +47,17 @@ final class SelectTypeFormViewController: UIViewController { } private let logoImageView = UIImageView().then { - $0.image = UIImage.appImage(asset: .koinLogo) + $0.image = UIImage.appImage(asset: .bcsdSymbolLogo) + $0.contentMode = .scaleAspectFit + } + + private let logoTextImageView = UIImageView().then { + $0.image = UIImage.appImage(asset: .koinTextLogo) + $0.contentMode = .scaleAspectFit } private let studentButton = UIButton().then { - $0.backgroundColor = .appColor(.sub500) + $0.backgroundColor = .appColor(.new500) $0.setTitle("한국기술교육대학교 학생", for: .normal) $0.setTitleColor(.white, for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) @@ -59,11 +65,13 @@ final class SelectTypeFormViewController: UIViewController { } private let generalButton = UIButton().then { - $0.backgroundColor = .appColor(.primary500) + $0.backgroundColor = .appColor(.neutral0) $0.setTitle("외부인", for: .normal) - $0.setTitleColor(.white, for: .normal) + $0.setTitleColor(.appColor(.new500), for: .normal) $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) $0.layer.cornerRadius = 8 + $0.layer.borderColor = UIColor.appColor(.new500).cgColor + $0.layer.borderWidth = 1 } // MARK: - Init @@ -133,7 +141,7 @@ extension SelectTypeFormViewController { // MARK: UI Settings extension SelectTypeFormViewController { private func setUpLayouts() { - [stepTextLabel, stepLabel, progressView, logoImageView, studentButton, generalButton].forEach { + [stepTextLabel, stepLabel, progressView, logoImageView, logoTextImageView, studentButton, generalButton].forEach { view.addSubview($0) } } @@ -156,14 +164,20 @@ extension SelectTypeFormViewController { } logoImageView.snp.makeConstraints { - $0.top.equalTo(progressView.snp.bottom).offset(100) + $0.top.equalTo(progressView.snp.bottom).offset(52) + $0.centerX.equalToSuperview() + $0.height.equalTo(66) + } + + logoTextImageView.snp.makeConstraints { + $0.top.equalTo(logoImageView.snp.bottom).offset(9) $0.centerX.equalToSuperview() - $0.width.greaterThanOrEqualTo(96) - $0.height.greaterThanOrEqualTo(56) + $0.width.equalTo(102) + $0.height.equalTo(41) } studentButton.snp.makeConstraints { - $0.top.equalTo(logoImageView.snp.bottom).offset(80) + $0.top.equalTo(logoTextImageView.snp.bottom).offset(52) $0.leading.equalToSuperview().offset(48) $0.trailing.equalToSuperview().offset(-48) $0.height.equalTo(48) diff --git a/Koin/Presentation/LostItem/EditLostItem/EditLostItemViewController.swift b/Koin/Presentation/LostItem/EditLostItem/EditLostItemViewController.swift index 5bb73423..87ab2e0e 100644 --- a/Koin/Presentation/LostItem/EditLostItem/EditLostItemViewController.swift +++ b/Koin/Presentation/LostItem/EditLostItem/EditLostItemViewController.swift @@ -58,6 +58,9 @@ final class EditLostItemViewController: UIViewController { $0.layer.masksToBounds = true } + // MARK: - Dropdown + private lazy var dropdownHost = KoinDropdownHost(scrollView: scrollView) + // MARK: - Initializer init(viewModel: EditLostItemViewModel) { self.viewModel = viewModel @@ -87,11 +90,7 @@ final class EditLostItemViewController: UIViewController { configureNavigationBar(style: .empty) } - override func hideKeyboardWhenTappedAround() { - super.hideKeyboardWhenTappedAround() - foundDateView.dismissDropdown() - } - + // MARK: - Bind private func bind() { viewModel.transform(with: inputSubject.eraseToAnyPublisher()).sink { [weak self] output in guard let self else { return } @@ -110,28 +109,7 @@ final class EditLostItemViewController: UIViewController { self?.addImageButtonTapped() }.store(in: &subscriptions) - imagesView.dismissDropDownPublisher.sink { [weak self] in - self?.foundDateView.dismissDropdown() - }.store(in: &subscriptions) - - categoryView.dismissDropDownPublisher.sink { [weak self] in - self?.foundDateView.dismissDropdown() - }.store(in: &subscriptions) - - foundPlaceView.shouldDismissDropDownPublisher.sink { [weak self] in - self?.foundDateView.dismissDropdown() - }.store(in: &subscriptions) - - contentView.shouldDismissDropDownPublisher.sink { [weak self] in - self?.foundDateView.dismissDropdown() - }.store(in: &subscriptions) - - foundDateView.focusDropdownPublisher.sink { [weak self] targetView in - guard let self else { return } - var rect = targetView.convert(targetView.bounds, to: scrollView) - rect.size.height += 15 - scrollView.scrollRectToVisible(rect, animated: true) - }.store(in: &subscriptions) + foundDateView.prepareDropdown(host: dropdownHost) } } @@ -142,8 +120,10 @@ extension EditLostItemViewController { } @objc private func editButtonTapped() { + guard !dropdownHost.isPresenting else { + return + } dismissKeyboard() - foundDateView.dismissDropdown() if foundDateView.isValid && foundPlaceView.isValid { let imageUrls = imagesView.imageUploadCollectionView.imageUrls @@ -194,6 +174,8 @@ extension EditLostItemViewController { return } + guard !dropdownHost.isPresenting else { return } + let contentInset = UIEdgeInsets( top: 0, left: 0, @@ -218,6 +200,8 @@ extension EditLostItemViewController { } @objc private func keyBoardWillHide(_ notification: NSNotification) { + guard !dropdownHost.isPresenting else { return } + let contentInset = UIEdgeInsets.zero scrollView.contentInset = contentInset scrollView.scrollIndicatorInsets = contentInset diff --git a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemCategoryView.swift b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemCategoryView.swift index 93089615..bbecf0b3 100644 --- a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemCategoryView.swift +++ b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemCategoryView.swift @@ -13,7 +13,6 @@ final class EditLostItemCategoryView: UIView { // MARK: - Properties @Published private(set) var selectedCategory: String = "" private var subscriptions: Set = [] - let dismissDropDownPublisher = PassthroughSubject() // MARK: - UI Components private let categoryLabel = UILabel().then { @@ -84,7 +83,6 @@ extension EditLostItemCategoryView { @objc private func buttonTapped(_ sender: EditLostItemButton) { selectedCategory = sender.title - dismissDropDownPublisher.send() endEditing(true) } } diff --git a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemContentView.swift b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemContentView.swift index d5646876..6cefdfd5 100644 --- a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemContentView.swift +++ b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemContentView.swift @@ -15,7 +15,6 @@ final class EditLostItemContentView: UIView { private var content: String? private let maxCharacters = 1000 private lazy var textViewPlaceHolder = "물품이나 \(type.description) 장소에 대한 추가 설명이 있다면 작성해주세요." - let shouldDismissDropDownPublisher = PassthroughSubject() // MARK: - UI Components private let contentLabel = UILabel().then { @@ -72,8 +71,6 @@ extension EditLostItemContentView: UITextViewDelegate { // MARK: 내용 수정 시작 func textViewDidBeginEditing(_ textView: UITextView) { - shouldDismissDropDownPublisher.send() - // placeholder 비우기 if textView.text == textViewPlaceHolder && textView.textColor == UIColor.appColor(.neutral500) { textView.text = "" diff --git a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift index 72afd3c3..1cde73e7 100644 --- a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift +++ b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundDateView.swift @@ -8,7 +8,7 @@ import UIKit import Combine -final class EditLostItemFoundDateView: ExtendedTouchAreaView { +final class EditLostItemFoundDateView: UIView { // MARK: - Properties private var type: LostItemType @@ -18,7 +18,6 @@ final class EditLostItemFoundDateView: ExtendedTouchAreaView { dateWarningLabel.isHidden } private(set) var foundDate: String - let focusDropdownPublisher = PassthroughSubject() // MARK: - UI Components private lazy var dateLabel = UILabel().then { @@ -68,12 +67,12 @@ final class EditLostItemFoundDateView: ExtendedTouchAreaView { private lazy var dropdownView = DatePickerDropdownView().then { $0.backgroundColor = UIColor.appColor(.neutral100) $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - $0.layer.applySketchShadow(color: UIColor.appColor(.neutral800), alpha: 0.08, x: 0, y: 4, blur: 10, spread: 0) - $0.isHidden = true - $0.transform = CGAffineTransform(translationX: 0, y: -20) - $0.alpha = 0 } + + // MARK: - Dropdown + var dropdownTrigger: UIView { dateButton } + var dropdownContentView: UIView & KoinDropdownContentView { dropdownView } + private var dropdown: KoinDropdown? // MARK: - Initializer init(type: LostItemType, foundDate: String) { @@ -107,9 +106,6 @@ final class EditLostItemFoundDateView: ExtendedTouchAreaView { dropdownView.valueChangedPublisher.sink { [weak self] in self?.dropdownValueChanged() }.store(in: &subscriptions) - dropdownView.dismissDropdownPublisher.sink { [weak self] in - self?.dismissDropdown() - }.store(in: &subscriptions) } private func setAddTargets() { @@ -117,49 +113,37 @@ final class EditLostItemFoundDateView: ExtendedTouchAreaView { } @objc private func dateButtonTapped(button: UIButton) { - if dropdownView.isHidden { - presentDropdown() - endEditing(true) - focusDropdownPublisher.send(dropdownView) - } else { - dismissDropdown() - } + endEditing(true) + dropdown?.toggle() } - - private func presentDropdown() { - // 열려있는 키보드 닫기 - self.endEditing(true) - - dropdownView.isHidden = false - UIView.animate(withDuration: 0.2) { [weak self] in - guard let self else { return } - dropdownView.alpha = 1 - dropdownView.transform = CGAffineTransform(translationX: 0, y: 0) - } - } - - @objc func dismissDropdown() { - UIView.animate(withDuration: 0.2) { [weak self] in - guard let self else { return } - dropdownView.alpha = 0 - dropdownView.transform = CGAffineTransform(translationX: 0, y: -20) - } - DispatchQueue.main.asyncAfter(deadline: .now()+0.1 ) { [weak self] in - self?.dropdownView.isHidden = true - } + + /// ScrollView 를 아는 호출부가 Host 를 넘겨준다. + func prepareDropdown(host: KoinDropdownHost) { + guard dropdown == nil else { return } + dropdown = host.makeDropdown( + trigger: dateButton, + contentView: dropdownView, + configuration: .init(topPadding: 4, shadow: .shadow2) + ) } private func dropdownValueChanged() { // shouldScrollTo(dropdownView) - let formattedDate = { + let displayDate = { let formatter = DateFormatter() formatter.dateFormat = "yyyy년 M월 d일" return formatter.string(from: dropdownView.dateValue) }() - dateButton.setTitle(formattedDate, for: .normal) + dateButton.setTitle(displayDate, for: .normal) dateButton.setTitleColor(UIColor.appColor(.neutral800), for: .normal) dateWarningLabel.isHidden = true + + let formattedDate = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: dropdownView.dateValue) + }() self.foundDate = formattedDate } } @@ -167,7 +151,7 @@ final class EditLostItemFoundDateView: ExtendedTouchAreaView { extension EditLostItemFoundDateView { private func setUpLayouts() { - [dateLabel, dateWarningLabel, dateButton, chevronImage, dropdownView, essentialLabel].forEach { + [dateLabel, dateWarningLabel, dateButton, chevronImage, essentialLabel].forEach { addSubview($0) } } @@ -195,10 +179,6 @@ extension EditLostItemFoundDateView { $0.centerY.equalTo(dateButton) $0.trailing.equalTo(dateButton.snp.trailing).offset(-16) } - dropdownView.snp.makeConstraints { - $0.top.equalTo(dateButton.snp.bottom).offset(4) - $0.leading.trailing.equalTo(dateButton) - } } private func configureView() { diff --git a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundPlaceView.swift b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundPlaceView.swift index 0c138e9e..5ac3d7a3 100644 --- a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundPlaceView.swift +++ b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemFoundPlaceView.swift @@ -13,7 +13,6 @@ final class EditLostItemFoundPlaceView: UIView { // MARK: - Properties private var type: LostItemType private lazy var textFieldPlaceHolder = "\(type.description) 장소를 입력해주세요." - let shouldDismissDropDownPublisher = PassthroughSubject() var isValid: Bool { locationWarningLabel.isHidden @@ -107,8 +106,6 @@ extension EditLostItemFoundPlaceView: UITextFieldDelegate { // MARK: 장소 수정 시작 func textFieldDidBeginEditing(_ textField: UITextField) { - shouldDismissDropDownPublisher.send() - // placeholder 비우기 if textField.textColor == UIColor.appColor(.neutral500) { textField.text = "" diff --git a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemImagesView.swift b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemImagesView.swift index 7a7eafce..51cb1f3f 100644 --- a/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemImagesView.swift +++ b/Koin/Presentation/LostItem/EditLostItem/SubViews/EditLostItemImagesView.swift @@ -13,7 +13,6 @@ final class EditLostItemImagesView: UIView { // MARK: - Properties private var type: LostItemType private var images: [AppImage] - let dismissDropDownPublisher = PassthroughSubject() let addImageButtonPublisher = PassthroughSubject() private var subscriptions: Set = [] @@ -79,12 +78,6 @@ final class EditLostItemImagesView: UIView { self?.addPictureButton.isEnabled = urls.count < 10 self?.pictureCountLabel.text = "\(urls.count)/10" }.store(in: &subscriptions) - - imageUploadCollectionView.shouldDismissDropDownKeyBoardPublisher.sink { [weak self] in - self?.dismissDropDownPublisher.send() - self?.endEditing(true) - }.store(in: &subscriptions) - } private func setAddTargets() { @@ -92,7 +85,6 @@ final class EditLostItemImagesView: UIView { } @objc private func addImageButtonTapped() { - dismissDropDownPublisher.send() addImageButtonPublisher.send() endEditing(true) } diff --git a/Koin/Presentation/Chat/Chat/BlockCheckModalViewController.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemBlockCheckModalViewController.swift similarity index 79% rename from Koin/Presentation/Chat/Chat/BlockCheckModalViewController.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemBlockCheckModalViewController.swift index ceb75a9c..9483bf70 100644 --- a/Koin/Presentation/Chat/Chat/BlockCheckModalViewController.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemBlockCheckModalViewController.swift @@ -1,15 +1,14 @@ // -// BlockCheckModalViewController.swift +// LostItemBlockCheckModalViewController.swift // koin // // Created by 김나훈 on 2/18/25. // -import Combine import UIKit -final class BlockCheckModalViewController: UIViewController { - let buttonPublihser = PassthroughSubject() +final class LostItemBlockCheckModalViewController: UIViewController { + private let onBlockButtonTapped: () -> Void private let blockButton = UIButton().then { var configuration = UIButton.Configuration.plain() @@ -30,6 +29,16 @@ final class BlockCheckModalViewController: UIViewController { $0.layer.shadowOffset = CGSize(width: 0, height: 2) $0.layer.shadowRadius = 4 } + + init(onBlockButtonTapped: @escaping () -> Void) { + self.onBlockButtonTapped = onBlockButtonTapped + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } override func viewDidLoad() { super.viewDidLoad() @@ -40,8 +49,7 @@ final class BlockCheckModalViewController: UIViewController { } @objc func blockButtonTapped() { - buttonPublihser.send() - dismiss(animated: true, completion: nil) + dismiss(animated: true, completion: onBlockButtonTapped) } @objc func tapOutsideOfContainerView(_ sender: UITapGestureRecognizer) { @@ -53,7 +61,7 @@ final class BlockCheckModalViewController: UIViewController { } -extension BlockCheckModalViewController { +extension LostItemBlockCheckModalViewController { private func setUpLayOuts() { [blockButton].forEach { diff --git a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatDateHeaderView.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatDateHeaderView.swift similarity index 84% rename from Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatDateHeaderView.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatDateHeaderView.swift index dd1271c9..5426efcf 100644 --- a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatDateHeaderView.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatDateHeaderView.swift @@ -1,5 +1,5 @@ // -// ChatDateHeaderView.swift +// LostItemChatDateHeaderView.swift // koin // // Created by 김나훈 on 2/20/25. @@ -7,7 +7,7 @@ import UIKit -final class ChatDateHeaderView: UITableViewHeaderFooterView { +final class LostItemChatDateHeaderView: UITableViewHeaderFooterView { // MARK: - UI Components @@ -34,13 +34,13 @@ final class ChatDateHeaderView: UITableViewHeaderFooterView { } -extension ChatDateHeaderView { - func configure(date: ChatDateInfo) { +extension LostItemChatDateHeaderView { + func configure(date: LostItemChatDateInfo) { dateLabel.text = "\(date.year)년 \(date.month)월 \(date.day)일" } } -extension ChatDateHeaderView { +extension LostItemChatDateHeaderView { private func setUpLayouts() { contentView.addSubview(dateLabel) diff --git a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatHistoryTableView.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatHistoryTableView.swift similarity index 77% rename from Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatHistoryTableView.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatHistoryTableView.swift index 24b2e17d..e762d84c 100644 --- a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatHistoryTableView.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatHistoryTableView.swift @@ -1,5 +1,5 @@ // -// ChatHistoryTableView.swift +// LostItemChatHistoryTableView.swift // koin // // Created by 김나훈 on 2/20/25. @@ -8,10 +8,10 @@ import Combine import UIKit -final class ChatHistoryTableView: UITableView { +final class LostItemChatHistoryTableView: UITableView { // MARK: - Properties - private var chatSections: [(date: ChatDateInfo, messages: [ChatMessage])] = [] + private var chatSections: [(date: LostItemChatDateInfo, messages: [LostItemChatMessage])] = [] let imageTapPublisher = PassthroughSubject() // MARK: - Initialization @@ -30,9 +30,9 @@ final class ChatHistoryTableView: UITableView { dataSource = self sectionHeaderTopPadding = 0 separatorStyle = .none - register(ChatImageTableViewCell.self, forCellReuseIdentifier: "ChatImageTableViewCell") - register(ChatTextTableViewCell.self, forCellReuseIdentifier: ChatTextTableViewCell.identifier) - register(ChatDateHeaderView.self, forHeaderFooterViewReuseIdentifier: ChatDateHeaderView.identifier) + register(LostItemChatImageTableViewCell.self, forCellReuseIdentifier: LostItemChatImageTableViewCell.identifier) + register(LostItemChatTextTableViewCell.self, forCellReuseIdentifier: LostItemChatTextTableViewCell.identifier) + register(LostItemChatDateHeaderView.self, forHeaderFooterViewReuseIdentifier: LostItemChatDateHeaderView.identifier) } override func layoutSubviews() { super.layoutSubviews() @@ -47,14 +47,14 @@ final class ChatHistoryTableView: UITableView { } // MARK: - 데이터 세팅 - func setChatHistory(item: [ChatMessage]) { + func setChatHistory(item: [LostItemChatMessage]) { chatSections = groupMessagesByDate(messages: item) reloadData() scrollToBottom(animated: false) } - private func groupMessagesByDate(messages: [ChatMessage]) -> [(date: ChatDateInfo, messages: [ChatMessage])] { - var groupedMessages: [(date: ChatDateInfo, messages: [ChatMessage])] = [] + private func groupMessagesByDate(messages: [LostItemChatMessage]) -> [(date: LostItemChatDateInfo, messages: [LostItemChatMessage])] { + var groupedMessages: [(date: LostItemChatDateInfo, messages: [LostItemChatMessage])] = [] for message in messages { if let lastSection = groupedMessages.last, lastSection.date.day == message.chatDateInfo.day { @@ -69,7 +69,7 @@ final class ChatHistoryTableView: UITableView { return groupedMessages } - func appendNewMessage(_ message: ChatMessage) { + func appendNewMessage(_ message: LostItemChatMessage) { if let lastSection = chatSections.last, lastSection.date.day == message.chatDateInfo.day { // 같은 날짜(day)면 기존 섹션에 메시지 추가 chatSections[chatSections.count - 1].messages.append(message) @@ -88,7 +88,7 @@ final class ChatHistoryTableView: UITableView { } // MARK: - UITableViewDataSource -extension ChatHistoryTableView: UITableViewDataSource { +extension LostItemChatHistoryTableView: UITableViewDataSource { private func scrollToBottom(animated: Bool) { guard !chatSections.isEmpty else { return } @@ -115,7 +115,7 @@ extension ChatHistoryTableView: UITableViewDataSource { let message = chatSections[indexPath.section].messages[indexPath.row] if message.isImage { - guard let cell = tableView.dequeueReusableCell(withIdentifier: "ChatImageTableViewCell", for: indexPath) as? ChatImageTableViewCell else { + guard let cell = tableView.dequeueReusableCell(withIdentifier: LostItemChatImageTableViewCell.identifier, for: indexPath) as? LostItemChatImageTableViewCell else { return UITableViewCell() } cell.configure(message: message) @@ -124,7 +124,7 @@ extension ChatHistoryTableView: UITableViewDataSource { }.store(in: &cell.cancellables) return cell } else { - guard let cell = tableView.dequeueReusableCell(withIdentifier: ChatTextTableViewCell.identifier, for: indexPath) as? ChatTextTableViewCell else { + guard let cell = tableView.dequeueReusableCell(withIdentifier: LostItemChatTextTableViewCell.identifier, for: indexPath) as? LostItemChatTextTableViewCell else { return UITableViewCell() } cell.configure(message: message) @@ -134,10 +134,10 @@ extension ChatHistoryTableView: UITableViewDataSource { } // MARK: - UITableViewDelegate (헤더) -extension ChatHistoryTableView: UITableViewDelegate { +extension LostItemChatHistoryTableView: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: ChatDateHeaderView.identifier) as? ChatDateHeaderView else { + guard let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: LostItemChatDateHeaderView.identifier) as? LostItemChatDateHeaderView else { return nil } header.configure(date: chatSections[section].date) diff --git a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatImageTableViewCell.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatImageTableViewCell.swift similarity index 91% rename from Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatImageTableViewCell.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatImageTableViewCell.swift index 5a838617..bfdaf8eb 100644 --- a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatImageTableViewCell.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatImageTableViewCell.swift @@ -1,5 +1,5 @@ // -// ChatImageTableViewCell.swift +// LostItemChatImageTableViewCell.swift // koin // // Created by 김나훈 on 2/20/25. @@ -8,7 +8,7 @@ import Combine import UIKit -final class ChatImageTableViewCell: UITableViewCell { +final class LostItemChatImageTableViewCell: UITableViewCell { // MARK: - Properties private var imageUrl: String? @@ -43,7 +43,7 @@ final class ChatImageTableViewCell: UITableViewCell { configureView() } - func configure(message: ChatMessage) { + func configure(message: LostItemChatMessage) { imageUrl = message.content textImageView.loadImageWithSpinner(from: message.content) timestampLabel.text = String(format: "%02d:%02d", message.chatDateInfo.hour, message.chatDateInfo.minute) @@ -60,14 +60,14 @@ final class ChatImageTableViewCell: UITableViewCell { } } -extension ChatImageTableViewCell { +extension LostItemChatImageTableViewCell { private func setUpLayouts() { [textImageView, timestampLabel].forEach { contentView.addSubview($0) } } - private func setUpConstraints(message: ChatMessage) { + private func setUpConstraints(message: LostItemChatMessage) { textImageView.snp.remakeConstraints { if message.isMine { $0.trailing.equalToSuperview().offset(-16) diff --git a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatTextTableViewCell.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatTextTableViewCell.swift similarity index 90% rename from Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatTextTableViewCell.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatTextTableViewCell.swift index e034f1db..d05c152e 100644 --- a/Koin/Presentation/Chat/Chat/ChatHistoryTableView/ChatTextTableViewCell.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemChatHistoryTableView/LostItemChatTextTableViewCell.swift @@ -1,5 +1,5 @@ // -// ChatTextTableViewCell.swift +// LostItemChatTextTableViewCell.swift // koin // // Created by 김나훈 on 2/20/25. @@ -7,7 +7,7 @@ import UIKit -final class ChatTextTableViewCell: UITableViewCell { +final class LostItemChatTextTableViewCell: UITableViewCell { // MARK: - UI Components private let messageLabel = InsetLabel(top: 10, left: 12, bottom: 10, right: 12).then { @@ -37,7 +37,7 @@ final class ChatTextTableViewCell: UITableViewCell { configureView() } - func configure(message: ChatMessage) { + func configure(message: LostItemChatMessage) { messageLabel.text = message.content messageLabel.backgroundColor = message.isMine ? UIColor.appColor(.neutral100) : UIColor.appColor(.info100) timestampLabel.text = String(format: "%02d:%02d", message.chatDateInfo.hour, message.chatDateInfo.minute) @@ -46,14 +46,14 @@ final class ChatTextTableViewCell: UITableViewCell { } -extension ChatTextTableViewCell { +extension LostItemChatTextTableViewCell { private func setUpLayouts() { [messageLabel, timestampLabel].forEach { contentView.addSubview($0) } } - private func setUpConstraints(message: ChatMessage) { + private func setUpConstraints(message: LostItemChatMessage) { messageLabel.snp.remakeConstraints { if message.isMine { $0.trailing.equalToSuperview().offset(-16) diff --git a/Koin/Presentation/Chat/Chat/ChatViewController.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemChatViewController.swift similarity index 86% rename from Koin/Presentation/Chat/Chat/ChatViewController.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemChatViewController.swift index 840abb31..14f1e93c 100644 --- a/Koin/Presentation/Chat/Chat/ChatViewController.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemChatViewController.swift @@ -1,5 +1,5 @@ // -// ChatViewController.swift +// LostItemChatViewController.swift // koin // // Created by 김나훈 on 2/16/25. @@ -9,11 +9,11 @@ import Combine import PhotosUI import UIKit -final class ChatViewController: UIViewController, UITextViewDelegate, PHPickerViewControllerDelegate { +final class LostItemChatViewController: UIViewController, UITextViewDelegate, PHPickerViewControllerDelegate { // // MARK: - Properties - private let viewModel: ChatViewModel - private let inputSubject: PassthroughSubject = .init() + private let viewModel: LostItemChatViewModel + private let inputSubject: PassthroughSubject = .init() private var subscriptions: Set = [] private var messageInputBottomConstraint: NSLayoutConstraint! private var textViewHeightConstraint: NSLayoutConstraint! @@ -44,16 +44,11 @@ final class ChatViewController: UIViewController, UITextViewDelegate, PHPickerVi $0.setImage(UIImage.appImage(asset: .send), for: .normal) } - private let blockCheckModalViewController = BlockCheckModalViewController().then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - - private let chatHistoryTableView = ChatHistoryTableView().then { + private let chatHistoryTableView = LostItemChatHistoryTableView().then { $0.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0) } - init(viewModel: ChatViewModel) { + init(viewModel: LostItemChatViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) let rightButton = UIBarButtonItem(image: UIImage.appImage(asset: .threeCircle), style: .plain, target: self, action: #selector(rightButtonTapped)) @@ -111,19 +106,6 @@ final class ChatViewController: UIViewController, UITextViewDelegate, PHPickerVi } }.store(in: &subscriptions) - blockCheckModalViewController.buttonPublihser.sink { [weak self] in - guard let self else { return } - let onRightButtonTapped: ()->Void = { [weak self] in - self?.inputSubject.send(.blockUser) - } - let modalViewController = ModalViewControllerB(onRightButtonTapped: onRightButtonTapped, width: 301, height: 179, paddingBetweenLabels: 8, title: "이 사용자를 차단하시겠습니까?", subTitle: "쪽지 수신 및 발신이 모두 차단됩니다.", titleColor: .appColor(.neutral700), subTitleColor: .appColor(.gray), rightButtonText: "차단하기") - modalViewController.modalTransitionStyle = .crossDissolve - modalViewController.modalPresentationStyle = .overFullScreen - dismiss(animated: true) { [weak self] in - self?.present(modalViewController, animated: true) - } - }.store(in: &subscriptions) - chatHistoryTableView.imageTapPublisher.sink { [weak self] imageUrl in self?.dismissKeyboard() @@ -134,7 +116,7 @@ final class ChatViewController: UIViewController, UITextViewDelegate, PHPickerVi } } -extension ChatViewController{ +extension LostItemChatViewController{ @objc private func sendButtonTapped() { if textView.text.isEmpty || textView.textColor == .appColor(.neutral500) { return } @@ -181,9 +163,33 @@ extension ChatViewController{ @objc private func rightButtonTapped() { dismissKeyboard() - + + let blockCheckModalViewController = LostItemBlockCheckModalViewController(onBlockButtonTapped: { [weak self] in + self?.presentBlockUserConfirmationModal() + }) + blockCheckModalViewController.modalPresentationStyle = .overFullScreen + blockCheckModalViewController.modalTransitionStyle = .crossDissolve present(blockCheckModalViewController, animated: true) } + + private func presentBlockUserConfirmationModal() { + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "이 사용자를 차단하시겠습니까?", + subTitleText: "쪽지 수신 및 발신이 모두 차단됩니다." + ), + button: .buttons( + leftButtonTitle: "닫기", + rightButtonTitle: "차단하기", + rightButtonAction: { [weak self] in + self?.inputSubject.send(.blockUser) + } + ) + )) + present(modalViewController, animated: true) + } + @objc private func keyboardWillShow(_ notification: Notification) { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let keyboardHeight = keyboardFrame.height @@ -246,7 +252,7 @@ extension ChatViewController{ } } -extension ChatViewController { +extension LostItemChatViewController { private func setUpLayOuts() { [bottomBackgroundView, chatHistoryTableView, messageInputView].forEach { diff --git a/Koin/Presentation/Chat/Chat/ChatViewModel.swift b/Koin/Presentation/LostItem/LostItemChat/LostItemChatViewModel.swift similarity index 85% rename from Koin/Presentation/Chat/Chat/ChatViewModel.swift rename to Koin/Presentation/LostItem/LostItemChat/LostItemChatViewModel.swift index 82b61e6c..3e4a0b57 100644 --- a/Koin/Presentation/Chat/Chat/ChatViewModel.swift +++ b/Koin/Presentation/LostItem/LostItemChat/LostItemChatViewModel.swift @@ -1,5 +1,5 @@ // -// ChatViewModel.swift +// LostItemChatViewModel.swift // koin // // Created by 김나훈 on 2/16/25. @@ -8,7 +8,7 @@ import Combine import Foundation -final class ChatViewModel: ViewModelProtocol { +final class LostItemChatViewModel: ViewModelProtocol { // MARK: - Input @@ -25,7 +25,7 @@ final class ChatViewModel: ViewModelProtocol { enum Output { case updateTitle(String) - case showChatHistory([ChatMessage]) + case showChatHistory([LostItemChatMessage]) case showToast(String, Bool) } @@ -33,13 +33,13 @@ final class ChatViewModel: ViewModelProtocol { private let outputSubject = PassthroughSubject() private var subscriptions: Set = [] private var pollingSubscriptions: AnyCancellable? - private let chatRepository = DefaultChatRepository(service: DefaultChatService()) - private lazy var fetchChatDetailUseCase = DefaultFetchChatDetailUseCase(chatRepository: chatRepository) - private lazy var blockUserUserCase = DefaultBlockUserUseCase(chatRepository: chatRepository) - private lazy var postChatDetailUseCase = DefaultPostChatDetailUseCase(chatRepository: chatRepository) + private let chatRepository = DefaultLostItemRepository(service: DefaultLostItemService()) + private lazy var fetchChatDetailUseCase = DefaultLostItemFetchChatDetailUseCase(chatRepository: chatRepository) + private lazy var blockUserUserCase = DefaultLostItemBlockUserUseCase(chatRepository: chatRepository) + private lazy var postChatDetailUseCase = DefaultLostItemPostChatDetailUseCase(chatRepository: chatRepository) private let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: DefaultUserRepository(service: DefaultUserService())) private lazy var uploadFileUseCase = DefaultUploadFileUseCase(coreRepository: DefaultCoreRepository(service: DefaultCoreService())) - private lazy var fetchChatRoomUseCase = DefaultFetchChatRoomUseCase(chatRepository: chatRepository) + private lazy var fetchChatRoomUseCase = DefaultLostItemFetchChatRoomUseCase(chatRepository: chatRepository) let articleId: Int let chatRoomId: Int private var articleTitle: String? @@ -76,7 +76,7 @@ final class ChatViewModel: ViewModelProtocol { } -extension ChatViewModel { +extension LostItemChatViewModel { private func uploadFiles(files: [Data]) { uploadFileUseCase.execute(files: files, domain: .lostItem).sink { [weak self] completion in @@ -106,10 +106,10 @@ extension ChatViewModel { pollingSubscriptions = Timer.publish(every: 1, on: .main, in: .common) .autoconnect() .prepend(Date()) - .flatMap { [weak self] _ -> AnyPublisher<[ChatMessage], Never> in + .flatMap { [weak self] _ -> AnyPublisher<[LostItemChatMessage], Never> in guard let self else { return Empty().eraseToAnyPublisher() } return fetchChatDetailUseCase.execute(userId: UserDataManager.shared.id, articleId: articleId, chatRoomId: chatRoomId) - .catch { error -> AnyPublisher<[ChatMessage], Never> in + .catch { error -> AnyPublisher<[LostItemChatMessage], Never> in return Empty().eraseToAnyPublisher() } .eraseToAnyPublisher() diff --git a/Koin/Presentation/Chat/ChatList/ChatListTableViewController.swift b/Koin/Presentation/LostItem/LostItemChatList/LostItemChatListTableViewController.swift similarity index 89% rename from Koin/Presentation/Chat/ChatList/ChatListTableViewController.swift rename to Koin/Presentation/LostItem/LostItemChatList/LostItemChatListTableViewController.swift index 5f0d88f2..e26279f8 100644 --- a/Koin/Presentation/Chat/ChatList/ChatListTableViewController.swift +++ b/Koin/Presentation/LostItem/LostItemChatList/LostItemChatListTableViewController.swift @@ -1,5 +1,5 @@ // -// ChatListTableViewController.swift +// LostItemChatListTableViewController.swift // koin // // Created by 김나훈 on 2/18/25. @@ -8,17 +8,17 @@ import Combine import UIKit -final class ChatListTableViewController: UITableViewController { +final class LostItemChatListTableViewController: UITableViewController { // MARK: - Properties - private let viewModel: ChatListTableViewModel - private let inputSubject: PassthroughSubject = .init() + private let viewModel: LostItemChatListTableViewModel + private let inputSubject: PassthroughSubject = .init() private var subscriptions: Set = [] // MARK: - UI Components - init(viewModel: ChatListTableViewModel) { + init(viewModel: LostItemChatListTableViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) navigationItem.title = "쪽지" @@ -35,7 +35,7 @@ final class ChatListTableViewController: UITableViewController { super.viewDidLoad() configureView() bind() - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "ChatCell") + tableView.register(UITableViewCell.self, forCellReuseIdentifier: UITableViewCell.identifier) tableView.separatorStyle = .none } @@ -65,11 +65,11 @@ final class ChatListTableViewController: UITableViewController { } } -extension ChatListTableViewController { +extension LostItemChatListTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { let chat = viewModel.chatList[indexPath.row] - let viewController = ChatViewController(viewModel: ChatViewModel(articleId: chat.articleId, chatRoomId: chat.chatRoomId, articleTitle: chat.articleTitle)) + let viewController = LostItemChatViewController(viewModel: LostItemChatViewModel(articleId: chat.articleId, chatRoomId: chat.chatRoomId, articleTitle: chat.articleTitle)) inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.messageListSelect, .click, "쪽지")) navigationController?.pushViewController(viewController, animated: true) } @@ -86,7 +86,7 @@ extension ChatListTableViewController { } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "ChatCell", for: indexPath) + let cell = tableView.dequeueReusableCell(withIdentifier: UITableViewCell.identifier, for: indexPath) let chat = viewModel.chatList[indexPath.row] cell.contentView.subviews.forEach { $0.removeFromSuperview() } let thumbnailContainerView = UIView().then { @@ -138,7 +138,7 @@ extension ChatListTableViewController { } titleLabel.text = chat.articleTitle contentLabel.text = "\(chat.recentMessageContent)" - recentTimeLabel.text = chat.lastMessageAt.toChatDateInfo().showingText + recentTimeLabel.text = chat.lastMessageAt.toLostItemChatDateInfo().showingText unreadMessageLabel.text = String(chat.unreadMessageCount) unreadMessageLabel.isHidden = chat.unreadMessageCount == 0 @@ -185,7 +185,7 @@ extension ChatListTableViewController { } -extension ChatListTableViewController { +extension LostItemChatListTableViewController { private func setUpLayOuts() { [].forEach { diff --git a/Koin/Presentation/Chat/ChatList/ChatListTableViewModel.swift b/Koin/Presentation/LostItem/LostItemChatList/LostItemChatListTableViewModel.swift similarity index 79% rename from Koin/Presentation/Chat/ChatList/ChatListTableViewModel.swift rename to Koin/Presentation/LostItem/LostItemChatList/LostItemChatListTableViewModel.swift index 571a5995..3aa4f667 100644 --- a/Koin/Presentation/Chat/ChatList/ChatListTableViewModel.swift +++ b/Koin/Presentation/LostItem/LostItemChatList/LostItemChatListTableViewModel.swift @@ -1,5 +1,5 @@ // -// ChatListTableViewModel.swift +// LostItemChatListTableViewModel.swift // koin // // Created by 김나훈 on 2/18/25. @@ -8,7 +8,7 @@ import Combine import Foundation -final class ChatListTableViewModel: ViewModelProtocol { +final class LostItemChatListTableViewModel: ViewModelProtocol { // MARK: - Input @@ -28,19 +28,18 @@ final class ChatListTableViewModel: ViewModelProtocol { private let outputSubject = PassthroughSubject() private var subscriptions: Set = [] private var pollingSubscriptions: AnyCancellable? - private(set) var chatList: [ChatRoomItem] = [] { + private(set) var chatList: [LostItemChatRoomItem] = [] { didSet { outputSubject.send(.showChatRoom) } } - private let chatRepository = DefaultChatRepository(service: DefaultChatService()) - private lazy var fetchChatRoomUseCase = DefaultFetchChatRoomUseCase(chatRepository: chatRepository) + private let chatRepository = DefaultLostItemRepository(service: DefaultLostItemService()) + private lazy var fetchChatRoomUseCase = DefaultLostItemFetchChatRoomUseCase(chatRepository: chatRepository) private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) // MARK: - Initialization - init() { - } + init() {} func transform(with input: AnyPublisher) -> AnyPublisher { input.sink { [weak self] input in @@ -60,17 +59,17 @@ final class ChatListTableViewModel: ViewModelProtocol { } -extension ChatListTableViewModel { +extension LostItemChatListTableViewModel { private func fetchChatRooms() { pollingSubscriptions?.cancel() pollingSubscriptions = Timer.publish(every: 1, on: .main, in: .common) .autoconnect() .prepend(Date()) - .flatMap { [weak self] _ -> AnyPublisher<[ChatRoomItem], Never> in + .flatMap { [weak self] _ -> AnyPublisher<[LostItemChatRoomItem], Never> in guard let self else { return Empty().eraseToAnyPublisher() } return fetchChatRoomUseCase.execute() - .catch { error -> AnyPublisher<[ChatRoomItem], Never> in + .catch { error -> AnyPublisher<[LostItemChatRoomItem], Never> in return Empty().eraseToAnyPublisher() }.eraseToAnyPublisher() } diff --git a/Koin/Presentation/LostItem/LostItemData/LostItemDataViewController.swift b/Koin/Presentation/LostItem/LostItemData/LostItemDataViewController.swift index 3b7c5adc..4f28859e 100644 --- a/Koin/Presentation/LostItem/LostItemData/LostItemDataViewController.swift +++ b/Koin/Presentation/LostItem/LostItemData/LostItemDataViewController.swift @@ -101,12 +101,12 @@ final class LostItemDataViewController: UIViewController { lostItemDataTableView.cellTappedPublisher.sink { [weak self] id in let userRepository = DefaultUserRepository(service: DefaultUserService()) let lostItemRepository = DefaultLostItemRepository(service: DefaultLostItemService()) - let chatRepository = DefaultChatRepository(service: DefaultChatService()) + let chatRepository = DefaultLostItemRepository(service: DefaultLostItemService()) let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: userRepository) let fetchLostItemDataUseCase = DefaultFetchLostItemDataUseCase(repository: lostItemRepository) let fetchLostItemListUseCase = DefaultFetchLostItemListUseCase(repository: lostItemRepository) let changeLostItemStateUseCase = DefaultChangeLostItemStateUseCase(repository: lostItemRepository) - let createChatRoomUseCase = DefaultCreateChatRoomUseCase(chatRepository: chatRepository) + let createChatRoomUseCase = DefaultLostItemCreateChatRoomUseCase(chatRepository: chatRepository) let deleteLostItemUseCase = DefaultDeleteLostItemUseCase(repository: lostItemRepository) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = LostItemDataViewModel( @@ -246,9 +246,15 @@ extension LostItemDataViewController { self?.inputSubject.send(.deleteData) self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.findUserDeleteConfirm, EventParameter.EventCategory.click, "확인")) } - let modalViewController = ModalViewControllerB(onRightButtonTapped: onRightButtonTapped, width: 301, height: 162, title: "삭제 시 되돌릴 수 없습니다.\n게시글을 삭제하시겠습니까?", titleColor: .appColor(.neutral600), rightButtonText: "확인") - modalViewController.modalPresentationStyle = .overFullScreen - modalViewController.modalTransitionStyle = .crossDissolve + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .singleTitle(text: "삭제 시 되돌릴 수 없습니다.\n게시글을 삭제하시겠습니까?"), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "확인", + rightButtonAction: onRightButtonTapped + ) + )) navigationController?.present(modalViewController, animated: true) } @@ -271,19 +277,25 @@ extension LostItemDataViewController { inputSubject.send(.changeState(viewModel.id)) inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.lostItemFound, .click, "\(type.description)물")) } - let modalViewController = ModalViewControllerB(onRightButtonTapped: onRightButtonTapped, width: 301, height: 162, title: "상태 변경 시 되돌릴 수 없습니다.\n찾음으로 변경하시겠습니까?", titleColor: .appColor(.neutral600), rightButtonText: "확인") - modalViewController.modalTransitionStyle = .crossDissolve - modalViewController.modalPresentationStyle = .overFullScreen + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .singleTitle(text: "상태 변경 시 되돌릴 수 없습니다.\n찾음으로 변경하시겠습니까?"), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "확인", + rightButtonAction: onRightButtonTapped + ) + )) navigationController?.present(modalViewController, animated: true) } - private func navigateToChat(_ createChatRoomResponse: CreateChatRoomResponse) { - let chatViewModel = ChatViewModel( + private func navigateToChat(_ createChatRoomResponse: LostItemCreateChatRoomResponse) { + let chatViewModel = LostItemChatViewModel( articleId: createChatRoomResponse.articleId, chatRoomId: createChatRoomResponse.chatRoomId, articleTitle: createChatRoomResponse.articleTitle ) - let viewController = ChatViewController(viewModel: chatViewModel) + let viewController = LostItemChatViewController(viewModel: chatViewModel) navigationController?.pushViewController(viewController, animated: true) } @@ -313,9 +325,19 @@ extension LostItemDataViewController { let loginViewController = LoginViewController(viewModel: viewModel) self?.navigationController?.pushViewController(loginViewController, animated: true) } - let modalViewController = ModalViewControllerB(onLeftButtonTapped: onLeftButtonTapped, onRightButtonTapped: onRightButtonTapped, width: 301, height: 208, paddingBetweenLabels: 16, title: "쪽지를 보내려면\n로그인이 필요해요.", subTitle: "로그인 후 대화를 시작하세요!", titleColor: .appColor(.neutral600), subTitleColor: .appColor(.gray)) - modalViewController.modalTransitionStyle = .crossDissolve - modalViewController.modalPresentationStyle = .overFullScreen + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "쪽지를 보내려면\n로그인이 필요해요.", + subTitleText: "로그인 후 대화를 시작하세요!" + ), + button: .buttons( + leftButtonTitle: "닫기", + leftButtonAction: onLeftButtonTapped, + rightButtonTitle: "로그인하기", + rightButtonAction: onRightButtonTapped + ) + )) navigationController?.present(modalViewController, animated: true) } @@ -340,9 +362,18 @@ extension LostItemDataViewController { let loginViewController = LoginViewController(viewModel: viewModel) self?.navigationController?.pushViewController(loginViewController, animated: true) } - let modalViewController = ModalViewControllerB(onRightButtonTapped: onRightButtonTapped, width: 301, height: 208, paddingBetweenLabels: 16, title: "게시글을 신고하려면\n로그인이 필요해요.", subTitle: "로그인 후 이용해주세요.", titleColor: .appColor(.neutral600), subTitleColor: .appColor(.gray)) - modalViewController.modalTransitionStyle = .crossDissolve - modalViewController.modalPresentationStyle = .overFullScreen + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "게시글을 신고하려면\n로그인이 필요해요.", + subTitleText: "로그인 후 이용해주세요." + ), + button: .buttons( + leftButtonTitle: "닫기", + rightButtonTitle: "로그인하기", + rightButtonAction: onRightButtonTapped + ) + )) navigationController?.present(modalViewController, animated: true) } diff --git a/Koin/Presentation/LostItem/LostItemData/LostItemDataViewModel.swift b/Koin/Presentation/LostItem/LostItemData/LostItemDataViewModel.swift index cbcc7cc2..12a69ea8 100644 --- a/Koin/Presentation/LostItem/LostItemData/LostItemDataViewModel.swift +++ b/Koin/Presentation/LostItem/LostItemData/LostItemDataViewModel.swift @@ -28,7 +28,7 @@ final class LostItemDataViewModel: ViewModelProtocol { case deletedData(Int) case popViewController case checkedLogin((CheckLoginOption, Bool)) - case navigateToChat(CreateChatRoomResponse) + case navigateToChat(LostItemCreateChatRoomResponse) } enum CheckLoginOption { @@ -42,7 +42,7 @@ final class LostItemDataViewModel: ViewModelProtocol { private let fetchLostItemListUseCase: FetchLostItemListUseCase private let changeLostItemStateUseCase: ChangeLostItemStateUseCase private let deleteLostItemUseCase: DeleteLostItemUseCase - private let createChatRoomUseCase: CreateChatRoomUseCase + private let createChatRoomUseCase: LostItemCreateChatRoomUseCase private let logAnalyticsEventUseCase: LogAnalyticsEventUseCase var type: LostItemType? private let outputSubject = PassthroughSubject() @@ -57,7 +57,7 @@ final class LostItemDataViewModel: ViewModelProtocol { fetchLostItemListUseCase: FetchLostItemListUseCase, changeLostItemStateUseCase: ChangeLostItemStateUseCase, deleteLostItemUseCase: DeleteLostItemUseCase, - createChatRoomUseCase: CreateChatRoomUseCase, + createChatRoomUseCase: LostItemCreateChatRoomUseCase, logAnalyticsEventUseCase: LogAnalyticsEventUseCase, id: Int) { self.checkLoginUseCase = checkLoginUseCase diff --git a/Koin/Presentation/LostItem/LostItemKeyword/LostItemKeywordViewController.swift b/Koin/Presentation/LostItem/LostItemKeyword/LostItemKeywordViewController.swift index b36d2e58..471bb1c5 100644 --- a/Koin/Presentation/LostItem/LostItemKeyword/LostItemKeywordViewController.swift +++ b/Koin/Presentation/LostItem/LostItemKeyword/LostItemKeywordViewController.swift @@ -195,19 +195,19 @@ extension LostItemKeywordViewController { let onRightButtonTapped: ()->Void = { [weak self] in self?.navigateToLogin() } - let viewController = ModalViewControllerB( - onRightButtonTapped: onRightButtonTapped, - width: 301, - height: 228, - paddingBetweenLabels: 16, - title: "키워드 알림을 받으려면\n로그인이 필요해요.", - subTitle: "로그인 후 간편하게 분실물 키워드\n알림을 받아보세요!", - titleColor: .appColor(.neutral800), - subTitleColor: .appColor(.gray) - ) - viewController.modalTransitionStyle = .crossDissolve - viewController.modalPresentationStyle = .overFullScreen - navigationController?.present(viewController, animated: true) + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "키워드 알림을 받으려면\n로그인이 필요해요.", + subTitleText: "로그인 후 간편하게 분실물 키워드\n알림을 받아보세요!" + ), + button: .buttons( + leftButtonTitle: "닫기", + rightButtonTitle: "로그인하기", + rightButtonAction: onRightButtonTapped + ) + )) + navigationController?.present(modalViewController, animated: true) } private func updateMyKeywordCountLabel(_ count: Int) { diff --git a/Koin/Presentation/LostItem/LostItemList/LostItemListViewController.swift b/Koin/Presentation/LostItem/LostItemList/LostItemListViewController.swift index 6ce92330..99e09adc 100644 --- a/Koin/Presentation/LostItem/LostItemList/LostItemListViewController.swift +++ b/Koin/Presentation/LostItem/LostItemList/LostItemListViewController.swift @@ -125,13 +125,13 @@ final class LostItemListViewController: UIViewController { let userRepository = DefaultUserRepository(service: DefaultUserService()) let lostItemRepository = DefaultLostItemRepository(service: DefaultLostItemService()) - let chatRepository = DefaultChatRepository(service: DefaultChatService()) + let chatRepository = DefaultLostItemRepository(service: DefaultLostItemService()) let checkLoginUseCase = DefaultCheckLoginUseCase(userRepository: userRepository) let fetchLostItemDataUseCase = DefaultFetchLostItemDataUseCase(repository: lostItemRepository) let fetchLostItemListUseCase = DefaultFetchLostItemListUseCase(repository: lostItemRepository) let changeLostItemStateUseCase = DefaultChangeLostItemStateUseCase(repository: lostItemRepository) let deleteLostItemUseCase = DefaultDeleteLostItemUseCase(repository: lostItemRepository) - let createChatRoomUseCase = DefaultCreateChatRoomUseCase(chatRepository: chatRepository) + let createChatRoomUseCase = DefaultLostItemCreateChatRoomUseCase(chatRepository: chatRepository) let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: GA4AnalyticsRepository(service: GA4AnalyticsService())) let viewModel = LostItemDataViewModel( checkLoginUseCase: checkLoginUseCase, @@ -227,11 +227,20 @@ extension LostItemListViewController { let loginViewController = LoginViewController(viewModel: viewModel) self?.navigationController?.pushViewController(loginViewController, animated: true) } - let loginModalViewController = ModalViewControllerB(onLeftButtonTapped: onLeftButtonTapped, onRightButtonTapped: onRightButtonTapped, width: 301, height: 208, paddingBetweenLabels: 16, title: "게시글을 작성하려면\n로그인이 필요해요.", subTitle: "로그인 후 글을 작성해주세요!", titleColor: UIColor.appColor(.neutral700), subTitleColor: UIColor.appColor(.gray)).then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - navigationController?.present(loginModalViewController, animated: true) + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "게시글을 작성하려면\n로그인이 필요해요.", + subTitleText: "로그인 후 글을 작성해주세요!" + ), + button: .buttons( + leftButtonTitle: "닫기", + leftButtonAction: onLeftButtonTapped, + rightButtonTitle: "로그인하기", + rightButtonAction: onRightButtonTapped + ) + )) + navigationController?.present(modalViewController, animated: true) } private func presentPostTypeModal() { diff --git a/Koin/Presentation/LostItem/PostLostItem/PostLostItemViewController.swift b/Koin/Presentation/LostItem/PostLostItem/PostLostItemViewController.swift index 92b2e467..545ef282 100644 --- a/Koin/Presentation/LostItem/PostLostItem/PostLostItemViewController.swift +++ b/Koin/Presentation/LostItem/PostLostItem/PostLostItemViewController.swift @@ -75,7 +75,7 @@ final class PostLostItemViewController: UIViewController { title = "\(viewModel.type.description)물 신고" addLostItemCollectionView.setType(type: viewModel.type) - configureTapGestureToDismissKeyboardDropdown() + hideKeyboardWhenTappedAround() } override func viewWillAppear(_ animated: Bool) { @@ -108,10 +108,6 @@ final class PostLostItemViewController: UIViewController { addLostItemCollectionView.logPublisher.sink { [weak self] value in self?.inputSubject.send(.logEvent(value.0, value.1, value.2)) }.store(in: &subscriptions) - - addLostItemCollectionView.shouldDismissKeyBoardPublisher.sink { [weak self] in - self?.dismissKeyboard() - }.store(in: &subscriptions) } } @@ -138,6 +134,8 @@ extension PostLostItemViewController { return } + guard !addLostItemCollectionView.isDropdownPresenting else { return } + let contentInset = UIEdgeInsets( top: 0, left: 0, @@ -160,6 +158,8 @@ extension PostLostItemViewController { } @objc private func keyBoardWillHide(_ notification: NSNotification) { + guard !addLostItemCollectionView.isDropdownPresenting else { return } + let contentInset = UIEdgeInsets.zero addLostItemCollectionView.contentInset = contentInset @@ -187,8 +187,6 @@ extension PostLostItemViewController: UITextViewDelegate, PHPickerViewController return allCellData } private func writeButtonTapped() { - dismissKeyboardDropdown() - var isAllValid = true for index in 0..() let uploadImageButtonPublisher = PassthroughSubject() - let shouldDismissKeyBoardPublisher = PassthroughSubject() let logPublisher = PassthroughSubject<(EventLabelType, EventParameter.EventCategory, Any), Never>() private var type: LostItemType = .lost private var articles: [PostLostItemRequest] = [] + var isDropdownPresenting: Bool { + dropdownHost.isPresenting + } + // MARK: - Initializer override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) { let flowLayout = UICollectionViewFlowLayout() @@ -52,6 +58,10 @@ final class AddLostItemCollectionView: UICollectionView { extension AddLostItemCollectionView { + func dismissDropdown() { + dropdownHost.dismissPresented() + } + func setType(type: LostItemType) { self.type = type reloadData() @@ -62,15 +72,6 @@ extension AddLostItemCollectionView { collectionViewLayout.invalidateLayout() } - func dismissDatePicker(_ currentIndexPath: IndexPath?) { - for row in 0.. UIView? { var addLostItemCollectionViewCells: [AddLostItemCollectionViewCell] = [] @@ -126,19 +127,7 @@ extension AddLostItemCollectionView: UICollectionViewDataSource { cell.imageUrlsPublisher.sink { [weak self] urls in self?.articles[indexPath.row].images = urls }.store(in: &cell.cancellables) - cell.shouldDismissDropDownPublisher.sink { [weak self] indexPath in - self?.dismissDatePicker(indexPath) - }.store(in: &cell.cancellables) - cell.shouldDismissKeyBoardPublisher.sink { [weak self] in - self?.shouldDismissKeyBoardPublisher.send() - }.store(in: &cell.cancellables) - cell.focusDropdownPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] targetView in - var rect = targetView.convert(targetView.bounds, to: self) - rect.size.height += 15 - self?.scrollRectToVisible(rect, animated: true) - }.store(in: &cell.cancellables) + cell.prepareDropdown(host: dropdownHost) return cell } @@ -162,7 +151,7 @@ extension AddLostItemCollectionView: UICollectionViewDataSource { formatter.dateFormat = "yyyy년 M월 d일" return formatter.string(from: Date()) }() - self?.dismissDatePicker(nil) + self?.dismissDropdown() DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { self?.articles.append(PostLostItemRequest(type: .found, category: "", location: "", foundDate: formattedDate, content: "", images: [], registeredAt: "", updatedAt: "")) self?.reloadData() @@ -172,9 +161,6 @@ extension AddLostItemCollectionView: UICollectionViewDataSource { case .lost: self?.logPublisher.send((EventParameter.EventLabel.Campus.lostItemAddItem, .click, "물품 추가")) } }.store(in: &footerCancellables) - footerView.shouldDismissDropDownPublisher.sink { [weak self] in - self?.dismissDatePicker(nil) - }.store(in: &footerCancellables) return footerView } return UICollectionReusableView() diff --git a/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift b/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift index 34a50b47..ea57d727 100644 --- a/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift +++ b/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemCollectionViewCell.swift @@ -20,9 +20,6 @@ final class AddLostItemCollectionViewCell: UICollectionViewCell { let locationPublisher = PassthroughSubject() let contentPublisher = PassthroughSubject() let imageUrlsPublisher = PassthroughSubject<[String], Never>() - let shouldDismissDropDownPublisher = PassthroughSubject() - let shouldDismissKeyBoardPublisher = PassthroughSubject() - let focusDropdownPublisher = PassthroughSubject() private var type: LostItemType = .lost private var textViewPlaceHolder = "" @@ -141,17 +138,7 @@ final class AddLostItemCollectionViewCell: UICollectionViewCell { $0.contentHorizontalAlignment = .left $0.titleEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) } - - private lazy var dropdownView = DatePickerDropdownView().then { - $0.backgroundColor = UIColor.appColor(.neutral100) - $0.layer.cornerRadius = 12 - $0.clipsToBounds = true - $0.layer.applySketchShadow(color: UIColor.appColor(.neutral800), alpha: 0.08, x: 0, y: 4, blur: 10, spread: 0) - $0.isHidden = true - $0.transform = CGAffineTransform(translationX: 0, y: -20) - $0.alpha = 0 - } - + private let locationLabel = UILabel().then { _ in } private let locationEssentialLabel = UILabel().then { @@ -196,6 +183,14 @@ final class AddLostItemCollectionViewCell: UICollectionViewCell { $0.text = textViewPlaceHolder } + // MARK: - Dropdown + private lazy var dropdownView = DatePickerDropdownView().then { + $0.backgroundColor = UIColor.appColor(.neutral100) + $0.layer.cornerRadius = 12 + } + + private var dropdown: KoinDropdown? + // MARK: - Initializer override init(frame: CGRect) { super.init(frame: frame) @@ -217,22 +212,25 @@ final class AddLostItemCollectionViewCell: UICollectionViewCell { self?.pictureCountLabel.text = "\(urls.count)/10" self?.imageUrlsPublisher.send(urls) }.store(in: &cancellable) - imageUploadCollectionView.shouldDismissDropDownKeyBoardPublisher.sink { [weak self] in - self?.shouldDismissDropDownPublisher.send(nil) - self?.shouldDismissKeyBoardPublisher.send() - }.store(in: &cancellable) dropdownView.valueChangedPublisher.sink { [weak self] in self?.dropdownValueChanged() }.store(in: &cancellable) - dropdownView.dismissDropdownPublisher.sink { [weak self] in - self?.shouldDismissDropDownPublisher.send(nil) - }.store(in: &cancellable) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: - PrepareForReuse + /// ScrollView 를 아는 CollectionView 가 Host 를 넘겨준다. 셀당 한 번만 만든다. + func prepareDropdown(host: KoinDropdownHost) { + guard dropdown == nil else { return } + dropdown = host.makeDropdown( + trigger: dateButton, + contentView: dropdownView, + configuration: .init(topPadding: 4, shadow: .shadow2) + ) + } + override func prepareForReuse() { super.prepareForReuse() cancellables.forEach { $0.cancel() } @@ -339,9 +337,6 @@ extension AddLostItemCollectionViewCell { extension AddLostItemCollectionViewCell{ @objc private func addImageButtonTapped() { - shouldDismissDropDownPublisher.send(nil) - shouldDismissKeyBoardPublisher.send() - addImageButtonPublisher.send() // TODO: 높이 해결 @@ -356,15 +351,10 @@ extension AddLostItemCollectionViewCell{ } @objc private func deleteCellButtonTapped() { - shouldDismissDropDownPublisher.send(nil) - shouldDismissKeyBoardPublisher.send() deleteButtonPublisher.send() } @objc private func stackButtonTapped(_ sender: UIButton) { - shouldDismissDropDownPublisher.send(nil) - shouldDismissKeyBoardPublisher.send() - categoryWarningLabel.isHidden = true categoryPublisher.send(sender.titleLabel?.text ?? "") categoryStackView.arrangedSubviews.forEach { view in @@ -475,43 +465,8 @@ extension AddLostItemCollectionViewCell { // MARK: - dropdown 열기/닫기 @objc private func dateButtonTapped(button: UIButton) { - if dropdownView.isHidden { - presentDropdown() - shouldDismissKeyBoardPublisher.send() - focusDropdownPublisher.send(dropdownView) - } else { - dismissDropdown() - } - } - - private func presentDropdown() { - - guard let text = itemCountLabel.text, - let lastCharacter = text.last, - let row = Int(String(lastCharacter)) else { - return - } - let indexPath = IndexPath(row: row - 1, section: 0) - - shouldDismissDropDownPublisher.send(indexPath) - - dropdownView.isHidden = false - UIView.animate(withDuration: 0.2) { [weak self] in - guard let self else { return } - dropdownView.alpha = 1 - dropdownView.transform = CGAffineTransform(translationX: 0, y: 0) - } - } - - @objc func dismissDropdown() { - UIView.animate(withDuration: 0.1) { [weak self] in - guard let self else { return } - dropdownView.alpha = 0 - dropdownView.transform = CGAffineTransform(translationX: 0, y: -20) - } - DispatchQueue.main.asyncAfter(deadline: .now()+0.1 ) { [weak self] in - self?.dropdownView.isHidden = true - } + // KoinDropdown 은 표시 중 키보드가 없다고 전제한다. 먼저 내린다. + dropdown?.toggle() } private func dropdownValueChanged() { @@ -531,9 +486,6 @@ extension AddLostItemCollectionViewCell: UITextViewDelegate { // MARK: 내용 수정 시작 func textViewDidBeginEditing(_ textView: UITextView) { - // 열려있는 드롭다운 닫기 - shouldDismissDropDownPublisher.send(nil) - // placeholder 비우기 if textView.text == textViewPlaceHolder && textView.textColor == UIColor.appColor(.neutral500) { textView.text = "" @@ -574,9 +526,6 @@ extension AddLostItemCollectionViewCell: UITextFieldDelegate { // MARK: 장소 수정 시작 func textFieldDidBeginEditing(_ textField: UITextField) { - // 열려있는 dropdown 닫기 - shouldDismissDropDownPublisher.send(nil) - // placeholder 비우기 if textField.textColor == UIColor.appColor(.neutral500) { textField.text = "" @@ -612,7 +561,7 @@ extension AddLostItemCollectionViewCell: UITextFieldDelegate { extension AddLostItemCollectionViewCell { private func setUpLayouts() { - [separateView, itemCountLabel, pictureLabel, pictureMessageLabel, pictureCountLabel, addPictureButton, categoryLabel, categoryMessageLabel, categoryStackView, dateLabel, locationLabel, locationTextField, contentLabel, contentTextCountLabel, contentTextView, deleteCellButton, categoryWarningLabel, dateWarningLabel, locationWarningLabel, imageUploadCollectionView, categoryEssentialLabel, dateEssentialLabel, locationEssentialLabel, dropdownView, dateButton].forEach { + [separateView, itemCountLabel, pictureLabel, pictureMessageLabel, pictureCountLabel, addPictureButton, categoryLabel, categoryMessageLabel, categoryStackView, dateLabel, locationLabel, locationTextField, contentLabel, contentTextCountLabel, contentTextView, deleteCellButton, categoryWarningLabel, dateWarningLabel, locationWarningLabel, imageUploadCollectionView, categoryEssentialLabel, dateEssentialLabel, locationEssentialLabel, dateButton].forEach { contentView.addSubview($0) } dateButton.addSubview(chevronImage) @@ -700,10 +649,6 @@ extension AddLostItemCollectionViewCell { $0.trailing.equalTo(contentView.snp.trailing).offset(-24) $0.height.equalTo(40) } - dropdownView.snp.makeConstraints { - $0.top.equalTo(dateButton.snp.bottom).offset(4) - $0.leading.trailing.equalTo(dateButton) - } chevronImage.snp.makeConstraints { make in make.centerY.equalToSuperview() diff --git a/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemFooterView.swift b/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemFooterView.swift index 6f0fc52b..697aec1f 100644 --- a/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemFooterView.swift +++ b/Koin/Presentation/LostItem/PostLostItem/SubViews/AddLostItemCollectionView/AddLostItemFooterView.swift @@ -12,7 +12,6 @@ final class AddLostItemFooterView: UICollectionReusableView { static let identifier = "AddLostItemFooterView" let addItemButtonPublisher = PassthroughSubject() - let shouldDismissDropDownPublisher = PassthroughSubject() private let addItemButton = UIButton().then { var configuration = UIButton.Configuration.plain() @@ -43,7 +42,6 @@ final class AddLostItemFooterView: UICollectionReusableView { extension AddLostItemFooterView { @objc private func addItemButtonTapped() { addItemButtonPublisher.send() - shouldDismissDropDownPublisher.send() self.endEditing(true) } diff --git a/Koin/Presentation/LostItem/PostLostItem/SubViews/DatePickerDropdownView.swift b/Koin/Presentation/LostItem/PostLostItem/SubViews/DatePickerDropdownView.swift index 79413830..b9f1423c 100644 --- a/Koin/Presentation/LostItem/PostLostItem/SubViews/DatePickerDropdownView.swift +++ b/Koin/Presentation/LostItem/PostLostItem/SubViews/DatePickerDropdownView.swift @@ -61,6 +61,17 @@ final class DatePickerDropdownView: UIView { } } +// MARK: - KoinDropdownContentView + +extension DatePickerDropdownView: KoinDropdownContentView { + var dismissTappedPublisher: AnyPublisher { + dismissDropdownPublisher.eraseToAnyPublisher() + } + var height: CGFloat { + return 161 + } +} + extension DatePickerDropdownView { private func setAddTargets() { diff --git a/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionView.swift b/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionView.swift index 59c968fb..559471d8 100644 --- a/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionView.swift +++ b/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionView.swift @@ -10,7 +10,6 @@ import UIKit final class LostItemImageCollectionView: UICollectionView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { - let shouldDismissDropDownKeyBoardPublisher = PassthroughSubject() let imageCountPublisher = PassthroughSubject<[String], Never>() private(set) var imageUrls: [String] = [] { didSet { @@ -59,9 +58,6 @@ extension LostItemImageCollectionView { self?.imageUrls.remove(at: indexPath.row) self?.reloadData() }.store(in: &cell.cancellables) - cell.shouldDismissDropDownKeyBoardPublisher.sink { [weak self] in - self?.shouldDismissDropDownKeyBoardPublisher.send() - }.store(in: &cell.cancellables) return cell } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { diff --git a/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionViewCell.swift b/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionViewCell.swift index db00f6cb..dbe5d703 100644 --- a/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionViewCell.swift +++ b/Koin/Presentation/LostItem/PostLostItem/SubViews/LostItemImageCollectionView/LostArticleImageCollectionViewCell.swift @@ -11,7 +11,6 @@ import UIKit final class LostItemImageCollectionViewCell: UICollectionViewCell { // MARK: - Properties - let shouldDismissDropDownKeyBoardPublisher = PassthroughSubject() let cancelButtonPublisher = PassthroughSubject() var cancellables = Set() @@ -47,7 +46,6 @@ final class LostItemImageCollectionViewCell: UICollectionViewCell { } @objc private func cancelButtonTapped() { - shouldDismissDropDownKeyBoardPublisher.send() cancelButtonPublisher.send(()) } diff --git a/Koin/Presentation/Notice/ManageNoticeKeyWord/ManageNoticeKeywordViewController.swift b/Koin/Presentation/Notice/ManageNoticeKeyWord/ManageNoticeKeywordViewController.swift index 94cb5d61..7a54ec76 100644 --- a/Koin/Presentation/Notice/ManageNoticeKeyWord/ManageNoticeKeywordViewController.swift +++ b/Koin/Presentation/Notice/ManageNoticeKeyWord/ManageNoticeKeywordViewController.swift @@ -88,11 +88,6 @@ final class ManageNoticeKeywordViewController: UIViewController { $0.text = "추천 키워드" } - private let keywordLoginModalViewController = ModalViewController(width: 301, height: 230, paddingBetweenLabels: 8, title: "키워드 알림을 받으려면\n로그인이 필요해요.", subTitle: "로그인 후 간편하게 공지사항 키워드\n알림을 받아보세요!", titleColor: .appColor(.neutral700), subTitleColor: .appColor(.gray)).then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - private let myKeywordCollectionView = MyKeywordCollectionView(frame: .zero, collectionViewLayout: LeftAlignedCollectionViewFlowLayout()) private let recommendedKeywordCollectionView = RecommendedKeywordCollectionView(frame: .zero, collectionViewLayout: LeftAlignedCollectionViewFlowLayout()) @@ -139,7 +134,7 @@ final class ManageNoticeKeywordViewController: UIViewController { case .showLoginModal: self.keywordNotificationSwtich.isOn = false self.keywordNotificationSwtich.isEnabled = true - self.present(self.keywordLoginModalViewController.self, animated: true, completion: nil) + self.presentLoginModal() case let .updateSwitch(isOn): self.keywordNotificationSwtich.isOn = isOn self.keywordNotificationSwtich.isEnabled = true @@ -174,31 +169,6 @@ final class ManageNoticeKeywordViewController: UIViewController { self?.inputSubject.send(.addKeyword(keyword: keyword, isRecommended: true)) }.store(in: &subscriptions) - keywordLoginModalViewController.rightButtonPublisher.sink { [weak self] in - let userRepository = DefaultUserRepository(service: DefaultUserService()) - let analyticsRepository = GA4AnalyticsRepository(service: GA4AnalyticsService()) - let notiRepository = DefaultNotiRepository(service: DefaultNotiService()) - let loginUseCase = DefaultLoginUseCase(userRepository: userRepository) - let logAnalyticsEventUseCase = DefaultLogAnalyticsEventUseCase(repository: analyticsRepository) - let fetchUserDataUseCase = DefaultFetchUserDataUseCase(userRepository: userRepository) - let sendDeviceTokenIfNeededUseCase = DefaultSendDeviceTokenIfNeededUseCase( - userRepository: userRepository, - notiRepository: notiRepository - ) - let viewModel = LoginViewModel( - loginUseCase: loginUseCase, - logAnalyticsEventUseCase: logAnalyticsEventUseCase, - fetchUserDataUseCase: fetchUserDataUseCase, - sendDeviceTokenIfNeededUseCase: sendDeviceTokenIfNeededUseCase - ) - let loginViewController = LoginViewController(viewModel: viewModel) - self?.navigationController?.pushViewController(loginViewController, animated: true) - self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.loginPrompt, .click, "키워드 알림 팝업")) - }.store(in: &subscriptions) - - keywordLoginModalViewController.leftButtonPublisher.sink { [weak self] in - self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.loginPopupKeyword, .click, "닫기")) - }.store(in: &subscriptions) } } @@ -241,7 +211,32 @@ extension ManageNoticeKeywordViewController { private func conductAddKeywordIllegalType(illegalType: String) { showToast(message: illegalType, success: false) } - + + private func presentLoginModal() { + let onLeftButtonTapped: ()->Void = { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.loginPopupKeyword, .click, "닫기")) + } + let onRightButtonTapped = { [weak self] in + self?.navigateToLogin() + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Campus.loginPrompt, .click, "키워드 알림 팝업")) + } + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .primary, + content: .titles( + mainTitleText: "키워드 알림을 받으려면\n로그인이 필요해요.", + subTitleText: "로그인 후 간편하게 공지사항 키워드\n알림을 받아보세요!" + ), + button: .buttons( + leftButtonTitle: "닫기", + leftButtonAction: onLeftButtonTapped, + rightButtonTitle: "로그인하기", + rightButtonAction: onRightButtonTapped + ), + layout: .init(width: 301) + )) + present(modalViewController, animated: true) + } + override func textFieldShouldReturn(_ textField: UITextField) -> Bool { if let text = textField.text { textField.text = "" @@ -347,4 +342,3 @@ extension ManageNoticeKeywordViewController { self.view.backgroundColor = .systemBackground } } - diff --git a/Koin/Presentation/Notice/NoticeData/NoticeDataViewController.swift b/Koin/Presentation/Notice/NoticeData/NoticeDataViewController.swift index ee1dcebc..ccf8e421 100644 --- a/Koin/Presentation/Notice/NoticeData/NoticeDataViewController.swift +++ b/Koin/Presentation/Notice/NoticeData/NoticeDataViewController.swift @@ -78,7 +78,11 @@ final class NoticeDataViewController: UIViewController, UIGestureRecognizerDeleg private let scrollView = UIScrollView() private let contentView = UIView().then { - $0.backgroundColor = .white + $0.backgroundColor = .appColor(.neutral100) + } + + private let aiSummaryViewHostingController = NoticeAISummaryViewHostingController().then { + $0.sizingOptions = .intrinsicContentSize } private let contentTextView = UITextView().then { @@ -96,14 +100,6 @@ final class NoticeDataViewController: UIViewController, UIGestureRecognizerDeleg $0.text = "첨부파일" } - private let separateView1 = UIView().then { - $0.backgroundColor = UIColor.appColor(.neutral100) - } - - private let separateView2 = UIView().then { - $0.backgroundColor = UIColor.appColor(.neutral100) - } - private let noticeAttachmentsTableView = NoticeAttachmentsTableView(frame: .zero, style: .plain) // MARK: - Initialization @@ -132,6 +128,7 @@ final class NoticeDataViewController: UIViewController, UIGestureRecognizerDeleg inputSubject.send(.getPopularNotices) commonConfigureView() + aiSummaryViewHostingController.didMove(toParent: self) inputSubject.send(.getNoticeData) } @@ -281,6 +278,8 @@ extension NoticeDataViewController { urlRedirectButton.isHidden = true } } + + aiSummaryViewHostingController.configure(summary: noticeData.aiSummary) } private func updatePopularArticle(notices: [NoticeArticleDto]) { @@ -332,9 +331,11 @@ extension NoticeDataViewController { } private func setUpLayOuts() { + addChild(aiSummaryViewHostingController) + view.addSubview(scrollView) scrollView.addSubview(contentView) - [titleWrappedView, contentWrappedView,popularNoticeWrappedView].forEach { + [titleWrappedView, aiSummaryViewHostingController.view, contentWrappedView,popularNoticeWrappedView].forEach { contentView.addSubview($0) } [titleGuideLabel, titleLabel, createdDateLabel, separatorDotLabel, nickNameLabel, separatorDot2Label, eyeImageView, hitLabel].forEach { @@ -401,10 +402,16 @@ extension NoticeDataViewController { $0.top.equalTo(nickNameLabel) $0.height.equalTo(19) } - contentWrappedView.snp.makeConstraints { + + aiSummaryViewHostingController.view.snp.makeConstraints { $0.top.equalTo(titleWrappedView.snp.bottom).offset(6) $0.leading.trailing.equalToSuperview() } + + contentWrappedView.snp.makeConstraints { + $0.top.equalTo(aiSummaryViewHostingController.view.snp.bottom).offset(6) + $0.leading.trailing.equalToSuperview() + } contentTextView.snp.makeConstraints { $0.top.equalToSuperview().offset(16) $0.leading.equalToSuperview().offset(24) diff --git a/Koin/Presentation/Notice/NoticeData/SubViews/NoticeAISummaryView.swift b/Koin/Presentation/Notice/NoticeData/SubViews/NoticeAISummaryView.swift new file mode 100644 index 00000000..4cca5cd2 --- /dev/null +++ b/Koin/Presentation/Notice/NoticeData/SubViews/NoticeAISummaryView.swift @@ -0,0 +1,110 @@ +// +// NoticeAISummaryView.swift +// koin +// +// Created by 홍기정 on 8/11/26. +// + +import SwiftUI + +final class NoticeAISummaryViewHostingController: UIHostingController { + + // MARK: - Initializer + init() { + super.init(rootView: NoticeAISummaryView(summary: .init(status: .loading, items: []))) + } + @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(summary: NoticeAISummary) { + rootView = NoticeAISummaryView(summary: summary) + } +} + +struct NoticeAISummaryView: View { + + // MARK: - Properties + let summary: NoticeAISummary + + // MARK: - Initializer + init(summary: NoticeAISummary) { + self.summary = summary + } + + // MARK: - Body + var body: some View { + VStack(alignment: .leading, spacing: 8) { + header + + switch summary.status { + case .loading: + EmptyView() + case .success: + successView + case .pending: + pendingView + case .unavailable: + unavailableView + } + } + .padding(.vertical, 12) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + var header: some View { + HStack(alignment: .center, spacing: 2) { + Text("AI 요약") + .font(.appFont(.pretendardMedium, size: 14)) + .foregroundStyle(Color.appColor(.new800)) + + Image.appImage(asset: .noticeAISummary) + + Spacer() + } + .frame(height: 22) + } + + @ViewBuilder + var pendingView: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + + Text("요약중...") + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(minHeight: 22) + + Spacer() + } + .padding(EdgeInsets(top: 40, leading: 0, bottom: 52, trailing: 0)) + } + + @ViewBuilder + var unavailableView: some View { + HStack(alignment: .center, spacing: 0) { + Spacer() + + Text("요약할 내용이 없어요.") + .font(.appFont(.pretendardRegular, size: 14)) + .foregroundStyle(Color.appColor(.neutral500)) + .frame(minHeight: 22) + + Spacer() + } + .padding(EdgeInsets(top: 40, leading: 0, bottom: 52, trailing: 0)) + } + + @ViewBuilder + var successView: some View { + VStack(alignment: .leading, spacing: 22) { + ForEach(summary.items) { item in + Text(item.attributedString) + .linespacing(fontSize: 14, percent: 160) + } + } + } +} diff --git a/Koin/Presentation/Setting/ChangeMyProfile/ChangeMyProfileViewController.swift b/Koin/Presentation/Setting/ChangeMyProfile/ChangeMyProfileViewController.swift index 8eb028b0..945912e5 100644 --- a/Koin/Presentation/Setting/ChangeMyProfile/ChangeMyProfileViewController.swift +++ b/Koin/Presentation/Setting/ChangeMyProfile/ChangeMyProfileViewController.swift @@ -27,11 +27,6 @@ final class ChangeMyProfileViewController: UIViewController { private let scrollView = UIScrollView().then { scrollView in } - private let revokeModalViewController = RevokeModalViewController().then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - private lazy var deptButton = UIButton().then { $0.isHidden = userType == .general ? true : false } @@ -55,6 +50,7 @@ final class ChangeMyProfileViewController: UIViewController { private let idTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)).then { $0.isUserInteractionEnabled = false + $0.keyboardType = .alphabet } private let nameTitleLabel = UILabel().then { @@ -81,7 +77,9 @@ final class ChangeMyProfileViewController: UIViewController { $0.text = "휴대전화" } - private let phoneTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)) + private let phoneTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)).then { + $0.keyboardType = .numberPad + } private let sendButton = StateButton(title: "인증번호 발송").then { $0.setState(state: .unusable) @@ -93,6 +91,7 @@ final class ChangeMyProfileViewController: UIViewController { private let certNumberTextField = DefaultTextField(placeholder: "인증번호를 입력해주세요.", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)).then { $0.isHidden = true + $0.keyboardType = .numberPad } private let remainTimeLabel = UILabel().then { @@ -122,7 +121,9 @@ final class ChangeMyProfileViewController: UIViewController { $0.text = "이메일" } - private let emailTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)) + private let emailTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)).then { + $0.keyboardType = .alphabet + } private lazy var emailTextLabel = UILabel().then { $0.text = "@koreatech.ac.kr" @@ -144,6 +145,7 @@ final class ChangeMyProfileViewController: UIViewController { private lazy var studentNumberTextField = DefaultTextField(placeholder: "", placeholderColor: UIColor.appColor(.neutral400), font: UIFont.appFont(.pretendardRegular, size: 14)).then { $0.isHidden = userType == .general ? true : false + $0.keyboardType = .numberPad } private lazy var majorTitleLabel = UILabel().then { @@ -177,8 +179,8 @@ final class ChangeMyProfileViewController: UIViewController { var updatedConfig = button.configuration ?? UIButton.Configuration.plain() let isSelected = button.isSelected updatedConfig.image = isSelected - ? UIImage(named: "circleCheckedPrimary500") - : UIImage(named: "circlePrimary500") + ? UIImage.appImage(asset: .circleCheckedPrimary500)?.withTintColor(.appColor(.new500)) + : UIImage.appImage(asset: .circlePrimary500)?.withTintColor(.appColor(.new500)) var text = AttributedString("남성") text.font = UIFont.appFont(.pretendardRegular, size: 12) updatedConfig.attributedTitle = text @@ -201,8 +203,8 @@ final class ChangeMyProfileViewController: UIViewController { var updatedConfig = button.configuration ?? UIButton.Configuration.plain() let isSelected = button.isSelected updatedConfig.image = isSelected - ? UIImage(named: "circleCheckedPrimary500") - : UIImage(named: "circlePrimary500") + ? UIImage.appImage(asset: .circleCheckedPrimary500)?.withTintColor(.appColor(.new500)) + : UIImage.appImage(asset: .circlePrimary500)?.withTintColor(.appColor(.new500)) var text = AttributedString("여성") text.font = UIFont.appFont(.pretendardRegular, size: 12) updatedConfig.attributedTitle = text @@ -214,6 +216,7 @@ final class ChangeMyProfileViewController: UIViewController { private let saveButton = StateButton(font: UIFont.appFont(.pretendardMedium, size: 15)).then { $0.setTitle("저장", for: .normal) $0.setState(state: .unusable) + $0.layer.cornerRadius = 8 } // MARK: - Initialization @@ -281,11 +284,6 @@ final class ChangeMyProfileViewController: UIViewController { // MARK: - Bind private func bind() { - - revokeModalViewController.revokeButtonPublisher.sink { [weak self] _ in - self?.viewModel.revoke() - }.store(in: &subscriptions) - viewModel.nicknameMessagePublisher.receive(on: DispatchQueue.main).sink { [weak self] response in self?.nicknameStateView.isHidden = false self?.nicknameStateView.setState(state: response.1 ? .success : .warning, message: response.0) @@ -387,6 +385,9 @@ final class ChangeMyProfileViewController: UIViewController { extension ChangeMyProfileViewController { @objc private func revokeButtonTapped() { + let revokeModalViewController = RevokeModalViewController { [weak self] in + self?.viewModel.revoke() + } present(revokeModalViewController, animated: true, completion: nil) } @objc private func inquryButtonTapped() { @@ -720,7 +721,7 @@ extension ChangeMyProfileViewController { helpLabel.font = UIFont.appFont(.pretendardRegular, size: 12) helpLabel.textColor = UIColor.appColor(.neutral500) inquryButton.titleLabel?.font = UIFont.appFont(.pretendardRegular, size: 12) - inquryButton.setTitleColor(UIColor.appColor(.primary500), for: .normal) + inquryButton.setTitleColor(UIColor.appColor(.new500), for: .normal) emailTextLabel.font = UIFont.appFont(.pretendardRegular, size: 14) emailTextLabel.textColor = .black } diff --git a/Koin/Presentation/Setting/ChangeMyProfile/RevokeModalViewController.swift b/Koin/Presentation/Setting/ChangeMyProfile/RevokeModalViewController.swift index b98f5d3c..5fb98f76 100644 --- a/Koin/Presentation/Setting/ChangeMyProfile/RevokeModalViewController.swift +++ b/Koin/Presentation/Setting/ChangeMyProfile/RevokeModalViewController.swift @@ -2,132 +2,49 @@ // RevokeModalViewController.swift // koin // -// Created by 김나훈 on 9/6/24. +// Created by 홍기정 on 8/16/26. // -import Combine import UIKit -final class RevokeModalViewController: UIViewController { - - let revokeButtonPublisher = PassthroughSubject() - - private let messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 18) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 8 - let text = "회원탈퇴를 하시겠습니까?" - let attributedString = NSMutableAttributedString(string: text) +final class RevokeModalViewController: KoinModalViewController { + + init(onRevokeButtonTapped: @escaping ()->Void) { + let mainTitle = { + let text = "회원탈퇴를 하시겠습니까?" + let revokeRange = (text as NSString).range(of: "회원탈퇴") + let fullRange = (text as NSString).range(of: text) + return NSMutableAttributedString(string: text).then { + $0.addAttribute(.foregroundColor, value: UIColor.appColor(.neutral700), range: fullRange) + $0.addAttribute(.foregroundColor, value: UIColor.appColor(.danger600), range: revokeRange) + $0.addAttribute(.font, value: UIFont.appFont(.pretendardMedium, size: 18), range: fullRange) + $0.addAttribute(.font, value: UIFont.appFont(.pretendardBold, size: 18), range: revokeRange) + } + }() + let subTitle = { + let text = "회원탈퇴를 하면 계정 복구가 불가능합니다." + let fullRange = (text as NSString).range(of: text) + return NSMutableAttributedString(string: text).then { + $0.addAttribute(.font, value: UIFont.appFont(.pretendardRegular, size: 14), range: fullRange) + $0.addAttribute(.foregroundColor, value: UIColor.appColor(.neutral500), range: fullRange) + } + }() - let loginRange = (text as NSString).range(of: "회원탈퇴") - attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.danger600), range: loginRange) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - $0.attributedText = attributedString - $0.textAlignment = .center - } - - - private let subMessageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 14) - $0.textColor = UIColor.appColor(.neutral500) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - let text = "회원탈퇴를 하면 계정 복구가 불가능합니다." - let attributedString = NSMutableAttributedString(string: text) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - $0.attributedText = attributedString - $0.textAlignment = .center - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let revokeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.danger600) - $0.setTitle("회원탈퇴", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - revokeButton.addTarget(self, action: #selector(revokeButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } - - @objc private func closeButtonTapped() { - dismiss(animated: true, completion: nil) - } - - @objc private func revokeButtonTapped() { - dismiss(animated: true, completion: nil) - revokeButtonPublisher.send(()) - } -} - -extension RevokeModalViewController { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, subMessageLabel, closeButton, revokeButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(301) - make.height.equalTo(179) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(24) - make.centerX.equalTo(containerView.snp.centerX) - } - subMessageLabel.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(16) - make.centerX.equalTo(containerView.snp.centerX) - } - closeButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - revokeButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) + super.init(configuration: .init( + appearance: .destructive, + content: .attributedTitles( + mainTitle: mainTitle, + subTitle: subTitle + ), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "회원탈퇴", + rightButtonAction: onRevokeButtonTapped + ) + )) + } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } } diff --git a/Koin/Presentation/Setting/ChangePassword/ChangePasswordViewController.swift b/Koin/Presentation/Setting/ChangePassword/ChangePasswordViewController.swift index 1303e4c3..8739243d 100644 --- a/Koin/Presentation/Setting/ChangePassword/ChangePasswordViewController.swift +++ b/Koin/Presentation/Setting/ChangePassword/ChangePasswordViewController.swift @@ -21,18 +21,18 @@ final class ChangePasswordViewController: UIViewController { private let progressTitleLabel = UILabel().then { $0.text = "1. 계정 인증" $0.font = UIFont.appFont(.pretendardMedium, size: 16) - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) } private let progressStepLabel = UILabel().then { $0.text = "1 / 2" $0.font = UIFont.appFont(.pretendardMedium, size: 16) - $0.textColor = UIColor.appColor(.primary500) + $0.textColor = UIColor.appColor(.new500) } private let progressView = UIProgressView().then { $0.trackTintColor = UIColor.appColor(.neutral300) - $0.progressTintColor = UIColor.appColor(.primary500) + $0.progressTintColor = UIColor.appColor(.new500) $0.layer.cornerRadius = 4 $0.clipsToBounds = true $0.progress = 0.5 @@ -47,6 +47,7 @@ final class ChangePasswordViewController: UIViewController { button.backgroundColor = UIColor.appColor(.neutral300) button.setTitleColor(UIColor.appColor(.neutral600), for: .normal) button.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) + button.layer.cornerRadius = 8 } private let certificationView = CertificationView(frame: .zero).then { view in @@ -85,7 +86,7 @@ final class ChangePasswordViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - configureNavigationBar(style: .fill) + configureNavigationBar(style: .empty) } @@ -97,7 +98,12 @@ final class ChangePasswordViewController: UIViewController { outputSubject.receive(on: DispatchQueue.main).sink { [weak self] output in switch output { case let .showToast(message, success, dismiss): - self?.showToast(message: message, success: success) + self?.showToastMessage(config: .init( + intent: success ? .neutral : .negative, + variant: .standard, + message: message, + bottomInset: 68 + )) if dismiss { self?.navigationController?.popViewController(animated: true) } @@ -117,12 +123,16 @@ final class ChangePasswordViewController: UIViewController { }.store(in: &subscriptions) } + override func textFieldShouldReturn(_ textField: UITextField) -> Bool { + completeButtonTapped() + return false + } } extension ChangePasswordViewController { private func changeButtonEnable(isEnable: Bool) { - completeButton.backgroundColor = isEnable ? UIColor.appColor(.primary500) : UIColor.appColor(.neutral300) + completeButton.backgroundColor = isEnable ? UIColor.appColor(.new500) : UIColor.appColor(.neutral300) completeButton.setTitleColor(isEnable ? UIColor.appColor(.neutral0) : UIColor.appColor(.neutral600), for: .normal) completeButton.isEnabled = isEnable ? true : false } @@ -190,9 +200,8 @@ extension ChangePasswordViewController { make.height.equalTo(300) } completeButton.snp.makeConstraints { make in - make.bottom.equalTo(view.snp.bottom).offset(-24) - make.leading.equalTo(view.snp.leading).offset(24) - make.trailing.equalTo(view.snp.trailing).offset(-24) + make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom).offset(-10) + make.leading.trailing.equalToSuperview().inset(24) make.height.equalTo(48) } } diff --git a/Koin/Presentation/Setting/ChangePassword/SubViews/CertificationView.swift b/Koin/Presentation/Setting/ChangePassword/SubViews/CertificationView.swift index 883ff0df..a38db9a7 100644 --- a/Koin/Presentation/Setting/ChangePassword/SubViews/CertificationView.swift +++ b/Koin/Presentation/Setting/ChangePassword/SubViews/CertificationView.swift @@ -18,8 +18,17 @@ final class CertificationView: UIView { $0.font = UIFont.appFont(.pretendardRegular, size: 14) } - private let idTextField = UITextField().then { textField in - textField.isUserInteractionEnabled = false + private let idLabelBackgroundView = UIView().then { + $0.backgroundColor = UIColor.appColor(.neutral100) + $0.layer.cornerRadius = 4 + $0.layer.masksToBounds = true + } + + private let idLabel = UILabel().then { + $0.font = .appFont(.pretendardRegular, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + $0.backgroundColor = .clear + $0.textAlignment = .left } private let passwordTitleLabel = UILabel().then { @@ -28,9 +37,19 @@ final class CertificationView: UIView { $0.font = UIFont.appFont(.pretendardRegular, size: 14) } - let passwordTextField = UITextField().then { textField in - textField.placeholder = "현재 비밀번호를 입력해주세요." - textField.isSecureTextEntry = true + let passwordTextField = UITextField().then { + $0.placeholder = "현재 비밀번호를 입력해주세요." + $0.isSecureTextEntry = true + + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + $0.backgroundColor = UIColor.appColor(.neutral100) + $0.layer.cornerRadius = 4 + $0.layer.masksToBounds = true + + let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: $0.frame.height)) + $0.leftView = paddingView + $0.leftViewMode = .always } private let changeSecureButton = UIButton().then { button in @@ -38,7 +57,7 @@ final class CertificationView: UIView { } private let errorResponseLabel = UILabel().then { - $0.textColor = UIColor.appColor(.sub500) + $0.textColor = UIColor.appColor(.new600) $0.font = UIFont.appFont(.pretendardRegular, size: 12) } @@ -56,7 +75,7 @@ final class CertificationView: UIView { } func fillEmailText(text: String) { - idTextField.text = text + idLabel.text = text } func getPasswordText() -> String { @@ -64,7 +83,7 @@ final class CertificationView: UIView { } func showErrorMessage(message: String) { - passwordTextField.layer.borderColor = UIColor.appColor(.sub500).cgColor + passwordTextField.layer.borderColor = UIColor.appColor(.new600).cgColor passwordTextField.layer.borderWidth = 1.0 errorResponseLabel.text = "⚠ \(message)" } @@ -84,7 +103,7 @@ extension CertificationView { extension CertificationView { private func setUpLayOuts() { - [idTitleLabel, idTextField, passwordTitleLabel, passwordTextField, errorResponseLabel, changeSecureButton].forEach { + [idTitleLabel, idLabelBackgroundView, idLabel, passwordTitleLabel, passwordTextField, errorResponseLabel, changeSecureButton].forEach { self.addSubview($0) } } @@ -94,13 +113,17 @@ extension CertificationView { make.top.equalTo(self.snp.top) make.leading.equalTo(self.snp.leading).offset(8) } - idTextField.snp.makeConstraints { make in + idLabelBackgroundView.snp.makeConstraints { make in make.top.equalTo(idTitleLabel.snp.bottom).offset(5) make.leading.trailing.equalToSuperview() make.height.equalTo(46) } + idLabel.snp.makeConstraints { make in + make.centerY.equalTo(idLabelBackgroundView) + make.leading.trailing.equalTo(idLabelBackgroundView).inset(16) + } passwordTitleLabel.snp.makeConstraints { make in - make.top.equalTo(idTextField.snp.bottom).offset(30) + make.top.equalTo(idLabel.snp.bottom).offset(30) make.leading.equalTo(self.snp.leading).offset(8) } passwordTextField.snp.makeConstraints { make in @@ -119,25 +142,10 @@ extension CertificationView { make.height.equalTo(20) } } - - private func setUpTextFields() { - [idTextField, passwordTextField].forEach { - $0.font = UIFont.appFont(.pretendardRegular, size: 14) - $0.textColor = UIColor.appColor(.neutral800) - $0.backgroundColor = UIColor.appColor(.neutral100) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - - let paddingView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: $0.frame.height)) - $0.leftView = paddingView - $0.leftViewMode = .always - } - } - + private func configureView() { setUpLayOuts() setUpConstraints() - setUpTextFields() self.backgroundColor = .systemBackground } diff --git a/Koin/Presentation/Setting/ChangePassword/SubViews/ChangePasswordView.swift b/Koin/Presentation/Setting/ChangePassword/SubViews/ChangePasswordView.swift index b404e616..f8c7a086 100644 --- a/Koin/Presentation/Setting/ChangePassword/SubViews/ChangePasswordView.swift +++ b/Koin/Presentation/Setting/ChangePassword/SubViews/ChangePasswordView.swift @@ -22,6 +22,7 @@ final class ChangePasswordView: UIView { let passwordTextField = UITextField().then { textField in textField.placeholder = "새 비밀번호를 입력해주세요." textField.isSecureTextEntry = true + textField.rightViewMode = .never } private let englishStackView = UIStackView().then { stackView in @@ -44,8 +45,9 @@ final class ChangePasswordView: UIView { let passwordCheckTextField = UITextField().then { textField in textField.placeholder = "새 비밀번호를 다시 입력해주세요." - textField.layer.borderColor = UIColor.appColor(.sub500).cgColor + textField.layer.borderColor = UIColor.appColor(.new600).cgColor textField.isSecureTextEntry = true + textField.rightViewMode = .never } private let changeSecureButton1 = UIButton().then { button in @@ -57,7 +59,7 @@ final class ChangePasswordView: UIView { } private let errorResponseLabel = UILabel().then { - $0.textColor = UIColor.appColor(.sub500) + $0.textColor = UIColor.appColor(.new600) $0.font = UIFont.appFont(.pretendardRegular, size: 12) } diff --git a/Koin/Presentation/Setting/Noti/NotiViewController.swift b/Koin/Presentation/Setting/Noti/NotiViewController.swift index 7bee9bd1..b6f633b0 100644 --- a/Koin/Presentation/Setting/Noti/NotiViewController.swift +++ b/Koin/Presentation/Setting/Noti/NotiViewController.swift @@ -81,7 +81,7 @@ final class NotiViewController: UIViewController { private let soldOutSwitch: UISwitch = { let uiSwitch = UISwitch() - uiSwitch.onTintColor = UIColor.appColor(.primary500) + uiSwitch.onTintColor = UIColor.appColor(.new500) return uiSwitch }() @@ -93,7 +93,7 @@ final class NotiViewController: UIViewController { private let diningImageUploadSwitch: UISwitch = { let uiSwitch = UISwitch() - uiSwitch.onTintColor = UIColor.appColor(.primary500) + uiSwitch.onTintColor = UIColor.appColor(.new500) return uiSwitch }() @@ -123,7 +123,7 @@ final class NotiViewController: UIViewController { private let chatSwitch: UISwitch = { let uiSwitch = UISwitch() - uiSwitch.onTintColor = UIColor.appColor(.primary500) + uiSwitch.onTintColor = UIColor.appColor(.new500) return uiSwitch }() @@ -183,13 +183,13 @@ final class NotiViewController: UIViewController { private let eventSwitch: UISwitch = { let uiSwitch = UISwitch() - uiSwitch.onTintColor = UIColor.appColor(.primary500) + uiSwitch.onTintColor = UIColor.appColor(.new500) return uiSwitch }() private let reviewSwitch: UISwitch = { let uiSwitch = UISwitch() - uiSwitch.onTintColor = UIColor.appColor(.primary500) + uiSwitch.onTintColor = UIColor.appColor(.new500) return uiSwitch }() @@ -235,9 +235,8 @@ final class NotiViewController: UIViewController { self.mealLabels.append(label) // 스위치 생성 let switchControl = UISwitch() - switchControl.onTintColor = UIColor.appColor(.primary500) + switchControl.onTintColor = UIColor.appColor(.new500) wrapView.addSubview(switchControl) - switchControl.transform = CGAffineTransformMakeScale(0.9, 0.75) switchControl.snp.makeConstraints { switchControl in switchControl.trailing.equalToSuperview().inset(21) switchControl.top.equalToSuperview().inset(15) @@ -278,7 +277,7 @@ final class NotiViewController: UIViewController { private let callVanSwitch: UISwitch = { let uiSwitch = UISwitch() - uiSwitch.onTintColor = UIColor.appColor(.primary500) + uiSwitch.onTintColor = UIColor.appColor(.new500) return uiSwitch }() @@ -320,7 +319,7 @@ final class NotiViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(true) - configureNavigationBar(style: .fill) + configureNavigationBar(style: .empty) } // MARK: - Bind @@ -548,7 +547,6 @@ extension NotiViewController { make.top.equalTo(16) } - soldOutSwitch.transform = CGAffineTransformMakeScale(0.9, 0.75) soldOutSwitch.snp.makeConstraints { make in make.trailing.equalTo(view.snp.trailing).inset(21) make.top.equalToSuperview().inset(15) @@ -583,7 +581,6 @@ extension NotiViewController { make.top.equalTo(16) } - diningImageUploadSwitch.transform = CGAffineTransformMakeScale(0.9, 0.75) diningImageUploadSwitch.snp.makeConstraints { make in make.trailing.equalTo(view.snp.trailing).inset(21) make.top.equalToSuperview().inset(15) @@ -614,7 +611,6 @@ extension NotiViewController { make.top.equalTo(chatNotiLabel.snp.bottom).offset(8) make.height.equalTo(17) } - chatSwitch.transform = CGAffineTransformMakeScale(0.9, 0.75) chatSwitch.snp.makeConstraints { make in make.trailing.equalTo(view.snp.trailing).inset(21) make.top.equalToSuperview().inset(15) @@ -645,9 +641,7 @@ extension NotiViewController { keywordNotiChevronImage.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(21) - make.top.equalToSuperview().inset(15) - make.width.equalTo(16) - make.height.equalTo(20) + make.centerY.equalToSuperview() } lostItemKeywordNotiLabel.snp.makeConstraints { make in @@ -664,9 +658,7 @@ extension NotiViewController { lostItemKeywordNotiChevronImage.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(21) - make.top.equalToSuperview().inset(15) - make.width.equalTo(16) - make.height.equalTo(20) + make.centerY.equalToSuperview() } shopGuideLabel.snp.makeConstraints { make in @@ -686,8 +678,7 @@ extension NotiViewController { make.height.equalTo(26) make.top.equalTo(16) } - - eventSwitch.transform = CGAffineTransformMakeScale(0.9, 0.75) + eventSwitch.snp.makeConstraints { make in make.trailing.equalTo(view.snp.trailing).inset(21) make.top.equalToSuperview().inset(15) @@ -710,7 +701,6 @@ extension NotiViewController { make.top.equalTo(16) } - reviewSwitch.transform = CGAffineTransformMakeScale(0.9, 0.75) reviewSwitch.snp.makeConstraints { make in make.trailing.equalTo(view.snp.trailing).inset(21) make.top.equalToSuperview().inset(15) @@ -736,7 +726,6 @@ extension NotiViewController { make.height.equalTo(26) make.top.equalTo(16) } - callVanSwitch.transform = CGAffineTransformMakeScale(0.9, 0.75) callVanSwitch.snp.makeConstraints { make in make.trailing.equalTo(view.snp.trailing).inset(21) make.top.equalToSuperview().inset(15) diff --git a/Koin/Presentation/Setting/Policy/PolicyViewController.swift b/Koin/Presentation/Setting/Policy/PolicyViewController.swift index 71c215df..a486b402 100644 --- a/Koin/Presentation/Setting/Policy/PolicyViewController.swift +++ b/Koin/Presentation/Setting/Policy/PolicyViewController.swift @@ -63,7 +63,7 @@ final class PolicyViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - configureNavigationBar(style: .fill) + configureNavigationBar(style: .empty) } private func bind() { diff --git a/Koin/Presentation/Setting/Settings/SettingsViewController.swift b/Koin/Presentation/Setting/Settings/SettingsViewController.swift index ab85e5d0..db250eb7 100644 --- a/Koin/Presentation/Setting/Settings/SettingsViewController.swift +++ b/Koin/Presentation/Setting/Settings/SettingsViewController.swift @@ -108,7 +108,7 @@ final class SettingsViewController: UIViewController { self.recentVersionLabel.textColor = UIColor.appColor(.neutral400) } else { self.recentVersionLabel.text = "최신 버전 \(version)" - self.recentVersionLabel.textColor = UIColor.appColor(.primary500) + self.recentVersionLabel.textColor = UIColor.appColor(.new500) } } } diff --git a/Koin/Presentation/Shared/Chat/Models/ChatListModel.swift b/Koin/Presentation/Shared/Chat/Models/ChatListModel.swift new file mode 100644 index 00000000..f94e9072 --- /dev/null +++ b/Koin/Presentation/Shared/Chat/Models/ChatListModel.swift @@ -0,0 +1,11 @@ +// +// ChatListModel.swift +// koin +// +// Created by 홍기정 on 8/20/26. +// + +struct ChatListModel { + let dates: [String] + let messages: [[ChatMessageRowModel]] +} diff --git a/Koin/Presentation/Shared/Chat/Models/ChatMessageRowModel.swift b/Koin/Presentation/Shared/Chat/Models/ChatMessageRowModel.swift new file mode 100644 index 00000000..ed35aaf3 --- /dev/null +++ b/Koin/Presentation/Shared/Chat/Models/ChatMessageRowModel.swift @@ -0,0 +1,28 @@ +// +// ChatMessageRowModel.swift +// koin +// +// Created by 홍기정 on 8/20/26. +// + +import UIKit + +struct ChatMessageRowModel { + let alignment: ChatMessageAlignment + let content: ChatMessageContent + let senderNickname: String + let timeText: String + let showsProfile: Bool + let isLeftUser: Bool + let profileImage: UIImage? +} + +enum ChatMessageAlignment { + case left + case right +} + +enum ChatMessageContent { + case text(String) + case image(String) +} diff --git a/Koin/Presentation/Shared/Chat/Views/ChatInputView.swift b/Koin/Presentation/Shared/Chat/Views/ChatInputView.swift new file mode 100644 index 00000000..471959cf --- /dev/null +++ b/Koin/Presentation/Shared/Chat/Views/ChatInputView.swift @@ -0,0 +1,151 @@ +// +// ChatInputView.swift +// koin +// +// Created by 홍기정 on 8/20/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class ChatInputView: UIView { + + // MARK: - Properties + let messageSendPublisher = PassthroughSubject() + let imageSendTappedPublisher = PassthroughSubject() + private let textViewPlaceHolder = "메시지 보내기" + + // MARK: - UI Components + private let sendImageButton = UIButton() + private let messageTextView = UITextView() + private let sendMessageButton = UIButton() + + // MARK: - Initializer + override init(frame: CGRect) { + super.init(frame: frame) + configureView() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - UITextViewDelegate + +extension ChatInputView: UITextViewDelegate { + func textViewDidBeginEditing(_ textView: UITextView) { + if textView.textColor == UIColor.appColor(.neutral500) { + textView.text = "" + textView.textColor = UIColor.appColor(.neutral800) + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + textView.text = textViewPlaceHolder + textView.textColor = UIColor.appColor(.neutral500) + } + } +} + +// MARK: - Actions + +private extension ChatInputView { + @objc private func sendImageButtonTapped() { + imageSendTappedPublisher.send() + } + + @objc private func sendMessageButtonTapped() { + guard messageTextView.textColor == UIColor.appColor(.neutral800) else { + return + } + + let text = messageTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + + messageSendPublisher.send(text) + messageTextView.text = "" + } +} + +// MARK: - Configure + +private extension ChatInputView { + private func configureView() { + setAddTargets() + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setAddTargets() { + sendImageButton.addTarget(self, action: #selector(sendImageButtonTapped), for: .touchUpInside) + sendMessageButton.addTarget(self, action: #selector(sendMessageButtonTapped), for: .touchUpInside) + } + + private func setUpStyles() { + backgroundColor = UIColor.appColor(.neutral100) + + sendImageButton.do { + $0.setImage(UIImage.appImage(asset: .callVanSendImage), for: .normal) + $0.backgroundColor = UIColor.appColor(.neutral0) + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + + messageTextView.do { + let font = UIFont.appFont(.pretendardRegular, size: 12) + let height: CGFloat = 32 + let topBottomInset = (height - font.lineHeight) / 2 + + $0.delegate = self + $0.isScrollEnabled = false + $0.font = font + $0.backgroundColor = UIColor.appColor(.neutral0) + $0.textContainerInset = UIEdgeInsets( + top: topBottomInset, + left: 16, + bottom: topBottomInset, + right: 16 + ) + $0.layer.cornerRadius = 12 + $0.text = textViewPlaceHolder + $0.textColor = UIColor.appColor(.neutral500) + } + + sendMessageButton.do { + $0.setImage(UIImage.appImage(asset: .callVanSendMessage), for: .normal) + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + } + + private func setUpLayouts() { + [sendImageButton, messageTextView, sendMessageButton].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + sendImageButton.snp.makeConstraints { + $0.size.equalTo(32) + $0.top.equalToSuperview().offset(8) + $0.leading.equalToSuperview().offset(24) + } + + sendMessageButton.snp.makeConstraints { + $0.size.equalTo(32) + $0.top.equalToSuperview().offset(8) + $0.trailing.equalToSuperview().offset(-24) + } + + messageTextView.snp.makeConstraints { + $0.top.bottom.equalToSuperview().inset(8) + $0.leading.equalTo(sendImageButton.snp.trailing).offset(8) + $0.trailing.equalTo(sendMessageButton.snp.leading).offset(-8) + $0.bottom.greaterThanOrEqualTo(sendImageButton) + } + } +} diff --git a/Koin/Presentation/Shared/Chat/Views/ChatListView.swift b/Koin/Presentation/Shared/Chat/Views/ChatListView.swift new file mode 100644 index 00000000..e733a150 --- /dev/null +++ b/Koin/Presentation/Shared/Chat/Views/ChatListView.swift @@ -0,0 +1,117 @@ +// +// ChatListView.swift +// koin +// +// Created by 홍기정 on 8/20/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class ChatListView: UIView { + + // MARK: - Properties + let messageSendPublisher = PassthroughSubject() + let imageSendTappedPublisher = PassthroughSubject() + let imageTappedPublisher = PassthroughSubject() + private var subscriptions = Set() + + // MARK: - UI Components + private let tableView = ChatTableView() + private let chatInputView = ChatInputView() + + // MARK: - Initializer + override init(frame: CGRect) { + super.init(frame: frame) + configureView() + bind() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func update(model: ChatListModel) { + tableView.configure(model: model) + } +} + +// MARK: - Bind + +private extension ChatListView { + private func bind() { + tableView.imageTappedPublisher + .sink { [weak self] imageUrl in + self?.imageTappedPublisher.send(imageUrl) + } + .store(in: &subscriptions) + + chatInputView.messageSendPublisher + .sink { [weak self] message in + self?.messageSendPublisher.send(message) + } + .store(in: &subscriptions) + + chatInputView.imageSendTappedPublisher + .sink { [weak self] in + self?.imageSendTappedPublisher.send() + } + .store(in: &subscriptions) + } +} + +// MARK: - Actions + +private extension ChatListView { + private func setGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapAround)) + tapGesture.cancelsTouchesInView = false + tableView.addGestureRecognizer(tapGesture) + } + + @objc private func didTapAround() { + endEditing(true) + } +} + +// MARK: - Configure + +private extension ChatListView { + private func configureView() { + setGesture() + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + backgroundColor = UIColor.appColor(.neutral100) + + tableView.do { + $0.transform = CGAffineTransform(rotationAngle: CGFloat(Double.pi)) + $0.backgroundColor = .white + $0.separatorStyle = .none + $0.showsVerticalScrollIndicator = false + } + } + + private func setUpLayouts() { + [tableView, chatInputView].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + tableView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.bottom.equalTo(chatInputView.snp.top) + } + + chatInputView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(keyboardLayoutGuide.snp.top) + } + } +} diff --git a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatDateHeaderView.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift similarity index 91% rename from Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatDateHeaderView.swift rename to Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift index b828c047..d4e449d8 100644 --- a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatDateHeaderView.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatDateHeaderView.swift @@ -1,5 +1,5 @@ // -// CallVanChatDateHeaderView.swift +// ChatDateHeaderView.swift // koin // // Created by 홍기정 on 3/9/26. @@ -9,7 +9,7 @@ import UIKit import SnapKit import Then -final class CallVanChatDateHeaderView: UITableViewHeaderFooterView { +final class ChatDateHeaderView: UITableViewHeaderFooterView { // MARK: - UI Components private let dateView = UIView() @@ -30,7 +30,7 @@ final class CallVanChatDateHeaderView: UITableViewHeaderFooterView { } } -extension CallVanChatDateHeaderView { +extension ChatDateHeaderView { private func configureView() { setUpStyles() diff --git a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatLeftCell.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift similarity index 89% rename from Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatLeftCell.swift rename to Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift index 8b761ae1..5a42889c 100644 --- a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatLeftCell.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatLeftCell.swift @@ -1,5 +1,5 @@ // -// CallVanChatLeftCell.swift +// ChatLeftCell.swift // koin // // Created by 홍기정 on 3/9/26. @@ -10,7 +10,7 @@ import Combine import SnapKit import Then -final class CallVanChatLeftCell: UITableViewCell { +final class ChatLeftCell: UITableViewCell { // MARK: - Properties let imageTappedPublisher = PassthroughSubject() @@ -55,35 +55,36 @@ final class CallVanChatLeftCell: UITableViewCell { } // MARK: - Public - func configure(message: CallVanChatMessage) { + func configure(message: ChatMessageRowModel) { // MARK: 프로필 - profileWrapperView.isHidden = !message.showProfile + profileWrapperView.isHidden = !message.showsProfile profileImageView.image = message.profileImage nicknameLabel.text = message.senderNickname leftUserLabel.isHidden = !message.isLeftUser // MARK: 이미지 - messageImageWrapperView.isHidden = !message.isImage - if message.isImage { - imageUrl = message.content - messageImageView.loadImageWithSpinner(from: message.content) - messageImageTimeLabel.text = message.time - } - - // MARK: 텍스트 - messageTextWrapperView.isHidden = message.isImage - if !message.isImage { + switch message.content { + case .image(let imageUrl): + messageImageWrapperView.isHidden = false + messageTextWrapperView.isHidden = true + self.imageUrl = imageUrl + messageImageView.loadImageWithSpinner(from: imageUrl) + messageImageTimeLabel.text = message.timeText + case .text(let text): + messageImageWrapperView.isHidden = true + messageTextWrapperView.isHidden = false + imageUrl = nil messageTextLabel.attributedText = NSAttributedString( - string: message.content, + string: text, attributes: messageTextLabelAttributes ) - messageTextTimeLabel.text = message.time + messageTextTimeLabel.text = message.timeText } } } -extension CallVanChatLeftCell { +extension ChatLeftCell { override func prepareForReuse() { super.prepareForReuse() messageImageView.image = nil @@ -106,7 +107,7 @@ extension CallVanChatLeftCell { } } -extension CallVanChatLeftCell { +extension ChatLeftCell { private func configureView() { setUpStyles() diff --git a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatRightCell.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift similarity index 87% rename from Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatRightCell.swift rename to Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift index 0d52874c..71a0bb4e 100644 --- a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatRightCell.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatRightCell.swift @@ -1,5 +1,5 @@ // -// CallVanChatRightCell.swift +// ChatRightCell.swift // koin // // Created by 홍기정 on 3/9/26. @@ -10,7 +10,7 @@ import Combine import SnapKit import Then -final class CallVanChatRightCell: UITableViewCell { +final class ChatRightCell: UITableViewCell { // MARK: - Properties let imageTappedPublisher = PassthroughSubject() @@ -52,32 +52,33 @@ final class CallVanChatRightCell: UITableViewCell { } // MARK: - Public - func configure(message: CallVanChatMessage) { + func configure(message: ChatMessageRowModel) { // MARK: 프로필 - profileWrapperView.isHidden = !message.showProfile + profileWrapperView.isHidden = !message.showsProfile // MARK: 이미지 - messageImageWrapperView.isHidden = !message.isImage - if message.isImage { - imageUrl = message.content - messageImageView.loadImageWithSpinner(from: message.content) - messageImageTimeLabel.text = message.time - } - - // MARK: 텍스트 - messageTextWrapperView.isHidden = message.isImage - if !message.isImage { + switch message.content { + case .image(let imageUrl): + messageImageWrapperView.isHidden = false + messageTextWrapperView.isHidden = true + self.imageUrl = imageUrl + messageImageView.loadImageWithSpinner(from: imageUrl) + messageImageTimeLabel.text = message.timeText + case .text(let text): + messageImageWrapperView.isHidden = true + messageTextWrapperView.isHidden = false + imageUrl = nil messageTextLabel.attributedText = NSAttributedString( - string: message.content, + string: text, attributes: messageTextLabelAttributes ) - messageTextTimeLabel.text = message.time + messageTextTimeLabel.text = message.timeText } } } -extension CallVanChatRightCell { +extension ChatRightCell { override func prepareForReuse() { super.prepareForReuse() messageImageView.image = nil @@ -100,7 +101,7 @@ extension CallVanChatRightCell { } } -extension CallVanChatRightCell { +extension ChatRightCell { private func configureView() { setUpStyles() diff --git a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatTableView.swift b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatTableView.swift similarity index 67% rename from Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatTableView.swift rename to Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatTableView.swift index e68f5c8d..82686d8d 100644 --- a/Koin/Presentation/CallVan/CallVanChat/Subviews/CallVanChatTableView/CallVanChatTableView.swift +++ b/Koin/Presentation/Shared/Chat/Views/ChatTableView/ChatTableView.swift @@ -1,5 +1,5 @@ // -// CallVanChatTableView.swift +// ChatTableView.swift // koin // // Created by 홍기정 on 3/9/26. @@ -9,12 +9,12 @@ import UIKit import Combine import Then -final class CallVanChatTableView: UITableView { +final class ChatTableView: UITableView { // MARK: - Properties let imageTappedPublisher = PassthroughSubject() private var dates: [String] = [] - private var messages: [[CallVanChatMessage]] = [] + private var messages: [[ChatMessageRowModel]] = [] // MARK: - Initializer init() { @@ -26,14 +26,14 @@ final class CallVanChatTableView: UITableView { } // MARK: - Public - func configure(callVanChat: CallVanChat) { - self.dates = callVanChat.dates - self.messages = callVanChat.messages + func configure(model: ChatListModel) { + self.dates = model.dates + self.messages = model.messages reloadData() } } -extension CallVanChatTableView { +extension ChatTableView { private func commonInit() { allowsSelection = false @@ -45,16 +45,16 @@ extension CallVanChatTableView { keyboardDismissMode = .interactiveWithAccessory delegate = self dataSource = self - register(CallVanChatLeftCell.self, forCellReuseIdentifier: CallVanChatLeftCell.identifier) - register(CallVanChatRightCell.self, forCellReuseIdentifier: CallVanChatRightCell.identifier) - register(CallVanChatDateHeaderView.self, forHeaderFooterViewReuseIdentifier: CallVanChatDateHeaderView.identifier) + register(ChatLeftCell.self, forCellReuseIdentifier: ChatLeftCell.identifier) + register(ChatRightCell.self, forCellReuseIdentifier: ChatRightCell.identifier) + register(ChatDateHeaderView.self, forHeaderFooterViewReuseIdentifier: ChatDateHeaderView.identifier) } } -extension CallVanChatTableView: UITableViewDelegate { +extension ChatTableView: UITableViewDelegate { func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - guard let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: CallVanChatDateHeaderView.identifier) as? CallVanChatDateHeaderView else { + guard let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: ChatDateHeaderView.identifier) as? ChatDateHeaderView else { return nil } headerView.configure(date: dates[section]) @@ -62,7 +62,7 @@ extension CallVanChatTableView: UITableViewDelegate { } } -extension CallVanChatTableView: UITableViewDataSource { +extension ChatTableView: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { return dates.count @@ -75,15 +75,16 @@ extension CallVanChatTableView: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let message = messages[indexPath.section][indexPath.row] - if message.isMine { - guard let cell = tableView.dequeueReusableCell(withIdentifier: CallVanChatRightCell.identifier, for: indexPath) as? CallVanChatRightCell else { + switch message.alignment { + case .right: + guard let cell = tableView.dequeueReusableCell(withIdentifier: ChatRightCell.identifier, for: indexPath) as? ChatRightCell else { return UITableViewCell() } cell.configure(message: message) bind(cell) return cell - } else { - guard let cell = tableView.dequeueReusableCell(withIdentifier: CallVanChatLeftCell.identifier, for: indexPath) as? CallVanChatLeftCell else { + case .left: + guard let cell = tableView.dequeueReusableCell(withIdentifier: ChatLeftCell.identifier, for: indexPath) as? ChatLeftCell else { return UITableViewCell() } cell.configure(message: message) @@ -92,13 +93,13 @@ extension CallVanChatTableView: UITableViewDataSource { } } - private func bind(_ cell: CallVanChatLeftCell) { + private func bind(_ cell: ChatLeftCell) { cell.imageTappedPublisher.sink { [weak self] imageUrl in self?.imageTappedPublisher.send(imageUrl) }.store(in: &cell.subscriptions) } - private func bind(_ cell: CallVanChatRightCell) { + private func bind(_ cell: ChatRightCell) { cell.imageTappedPublisher.sink { [weak self] imageUrl in self?.imageTappedPublisher.send(imageUrl) }.store(in: &cell.subscriptions) diff --git a/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift b/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift new file mode 100644 index 00000000..3f45112f --- /dev/null +++ b/Koin/Presentation/Shared/FilterBottomSheet/FilterBottomSheetView.swift @@ -0,0 +1,251 @@ +// +// FilterBottomSheetView.swift +// koin +// +// Created by 홍기정 on 8/27/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +class FilterBottomSheetView: UIView { + + // MARK: - Properties + weak var delegate: BottomSheetViewControllerBDelegate? + private var groupModels: [FilterGroupModel] + private let onFilterItemTapped: (FilterItemModel)->Bool + private let onApplyTapped: ([FilterGroupModel])->Void + private var subscriptions: Set = [] + + // MARK: - UI Components + private let titleLabel = UILabel() + private let closeButton = UIButton() + private let topSeparatorView = UIView() + + private let filterGroupScrollView = UIScrollView() + private let filterGroupStackView = UIStackView() + private let filterGroupViews: [FilterGroupView] + + private let resetButton = UIButton() + private let applyButton = UIButton() + private let bottomSeparatorView = UIView() + + + // MARK: - Initializer + init( + groupModels: [FilterGroupModel], + onFilterItemTapped: @escaping (FilterItemModel)->Bool, + onApplyTapped: @escaping ([FilterGroupModel])->Void + ) { + self.groupModels = groupModels + self.onFilterItemTapped = onFilterItemTapped + self.onApplyTapped = onApplyTapped + self.filterGroupViews = groupModels.map { group in + FilterGroupView(filterGroup: group) + } + + super.init(frame: .zero) + + configureView() + setAddTargets() + bind() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Bind + private func bind() { + for groupIndex in filterGroupViews.indices { + filterGroupViews[groupIndex].itemTappedPublisher + .sink { [weak self] itemIndex in + self?.didTapItem(groupIndex: groupIndex, itemIndex: itemIndex) + } + .store(in: &subscriptions) + } + } + + private func didTapItem(groupIndex: Int, itemIndex: Int) { + let tappedItem = groupModels[groupIndex].items[itemIndex] + guard onFilterItemTapped(tappedItem) else { + return + } + + let before = groupModels[groupIndex].items.map(\.isSelected) + groupModels[groupIndex].didTap(itemAt: itemIndex) + let after = groupModels[groupIndex].items.map(\.isSelected) + + filterGroupViews[groupIndex].update( + filterGroup: groupModels[groupIndex], + changed: Self.changedIndexPaths(before: before, after: after) + ) + } + + private static func changedIndexPaths(before: [Bool], after: [Bool]) -> [IndexPath] { + zip(before, after).enumerated().compactMap { index, pair in + pair.0 != pair.1 ? IndexPath(row: index, section: 0) : nil + } + } +} + +extension FilterBottomSheetView { + private func setAddTargets() { + closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + resetButton.addTarget(self, action: #selector(resetButtonTapped), for: .touchUpInside) + applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside) + } + + @objc private func closeButtonTapped() { + delegate?.dismiss() + } + + @objc private func resetButtonTapped() { + for groupIndex in groupModels.indices { + let before = groupModels[groupIndex].items.map(\.isSelected) + groupModels[groupIndex].reset() + let after = groupModels[groupIndex].items.map(\.isSelected) + + filterGroupViews[groupIndex].update( + filterGroup: groupModels[groupIndex], + changed: Self.changedIndexPaths(before: before, after: after) + ) + } + } + + @objc private func applyButtonTapped() { + onApplyTapped(groupModels) + delegate?.dismiss() + } +} + +extension FilterBottomSheetView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + self.do { + $0.backgroundColor = .appColor(.neutral0) + $0.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + $0.layer.cornerRadius = 32 + } + + titleLabel.do { + $0.text = "필터" + $0.font = UIFont.appFont(.pretendardSemiBold, size: 18) + $0.textColor = UIColor.appColor(.new500) + } + + closeButton.do { + $0.setImage(.appImage(asset: .newCancel), for: .normal) + $0.tintColor = UIColor.appColor(.neutral800) + } + + [topSeparatorView, bottomSeparatorView].forEach { + $0.do { + $0.backgroundColor = UIColor.appColor(.neutral200) + } + } + + filterGroupScrollView.do { +// $0.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + $0.showsVerticalScrollIndicator = false + } + + filterGroupStackView.do { + $0.axis = .vertical + $0.alignment = .fill + $0.distribution = .fill + $0.spacing = 12 + } + + resetButton.do { + var configuration = UIButton.Configuration.plain() + configuration.attributedTitle = AttributedString("초기화", attributes: AttributeContainer([ + .font : UIFont.appFont(.pretendardSemiBold, size: 16), + .foregroundColor : UIColor.appColor(.neutral600) + ])) + configuration.image = UIImage.appImage(asset: .refresh) + configuration.imagePadding = 8 + configuration.imagePlacement = .trailing + configuration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16) + $0.configuration = configuration + $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 12 + $0.clipsToBounds = true + } + resetButton.setContentHuggingPriority(.required, for: .horizontal) + resetButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + applyButton.do { + $0.setAttributedTitle(NSAttributedString( + string: "적용하기", + attributes: [ + .font : UIFont.appFont(.pretendardSemiBold, size: 16), + .foregroundColor : UIColor.appColor(.neutral0) + ]), for: .normal) + $0.backgroundColor = UIColor.appColor(.new500) + $0.layer.cornerRadius = 12 + } + } + + private func setUpLayouts() { + filterGroupViews.forEach { + filterGroupStackView.addArrangedSubview($0) + } + [filterGroupStackView].forEach { + filterGroupScrollView.addSubview($0) + } + [titleLabel, closeButton, topSeparatorView, filterGroupScrollView, resetButton, applyButton, bottomSeparatorView].forEach { + addSubview($0) + } + } + + private func setUpConstraints() { + titleLabel.snp.makeConstraints { + $0.height.equalTo(29) + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(32) + } + closeButton.snp.makeConstraints { + $0.centerY.equalTo(titleLabel) + $0.trailing.equalToSuperview().offset(-24) + } + topSeparatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview() + } + filterGroupScrollView.snp.makeConstraints { + $0.top.equalTo(topSeparatorView.snp.bottom) + $0.leading.trailing.equalToSuperview().inset(32) + $0.height.equalTo(filterGroupScrollView.contentLayoutGuide.snp.height).priority(.medium) + } + filterGroupStackView.snp.makeConstraints { +// $0.edges.equalTo(filterGroupScrollView.contentLayoutGuide) + $0.leading.trailing.equalTo(filterGroupScrollView.contentLayoutGuide) + $0.top.bottom.equalTo(filterGroupScrollView.contentLayoutGuide).inset(12) + $0.width.equalTo(filterGroupScrollView) + } + resetButton.snp.makeConstraints { + $0.height.equalTo(48) + $0.top.equalTo(filterGroupScrollView.snp.bottom).offset(12) + $0.leading.equalToSuperview().offset(32) + } + applyButton.snp.makeConstraints { + $0.top.bottom.equalTo(resetButton) + $0.leading.equalTo(resetButton.snp.trailing).offset(12) + $0.trailing.equalToSuperview().offset(-32) + } + bottomSeparatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.top.equalTo(resetButton.snp.bottom).offset(12) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterGroupModel.swift b/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterGroupModel.swift new file mode 100644 index 00000000..3b7bd16d --- /dev/null +++ b/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterGroupModel.swift @@ -0,0 +1,114 @@ +// +// FilterItemGroup.swift +// koin +// +// Created by 홍기정 on 8/27/26. +// + +import Foundation + +struct FilterGroupModel { + + enum Behavior { + case single + case multiple + } + + let title: String + let description: String? + let hasAllButton: Bool + let behavior: Behavior + private(set) var items: [FilterItemModel] = [] + + init( + title: String, + description: String? = nil, + hasAllButton: Bool, + items: [String], + behavior: Behavior, + allowEmptySelection: Bool = false + ) { + self.title = title + self.description = description + self.hasAllButton = hasAllButton + self.behavior = behavior + + if hasAllButton { + self.items.append(FilterItemModel(title: "전체")) + } + for item in items { + self.items.append(FilterItemModel(title: item)) + } + + if !self.items.contains(where: { $0.isSelected }) { + reset(allowEmptySelection) + } + } +} + +extension FilterGroupModel { + var selectedItems: [FilterItemModel] { + items.filter { $0.isSelected } + } +} + +extension FilterGroupModel { + mutating func reset(_ allowEmptySelection: Bool = false) { + let selectedIndex: Int? = allowEmptySelection ? nil : 0 + deselectAll(except: selectedIndex) + } + + mutating func didTap(itemAt index: Int) { + switch items[index].isSelected { + case true: + deselect(itemAt: index) + case false: + select(itemAt: index) + } + } +} + +extension FilterGroupModel { + mutating private func deselectAll(except selectedIndex: Int?) { + for index in items.indices { + items[index].isSelected = index == selectedIndex + } + } + + mutating private func select(itemAt selectedIndex: Int) { + let selectedItem = items[selectedIndex].title + let didTapAll = selectedItem == "전체" + + if didTapAll { + deselectAll(except: 0) + return + } + + switch behavior { + case .single: + deselectAll(except: selectedIndex) + case .multiple: + if items[0].title == "전체" { + items[0].isSelected = false + } + items[selectedIndex].isSelected = true + } + } + + mutating private func deselect(itemAt deselectedIndex: Int) { + let deselectedItem = items[deselectedIndex].title + let didTapAll = deselectedItem == "전체" + + if didTapAll { + return + } + switch behavior { + case .single: + return + case .multiple: + if 1 < selectedItems.count { + items[deselectedIndex].isSelected = false + } + } + } +} diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterItemModel.swift b/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterItemModel.swift new file mode 100644 index 00000000..76af6265 --- /dev/null +++ b/Koin/Presentation/Shared/FilterBottomSheet/Model/FilterItemModel.swift @@ -0,0 +1,22 @@ +// +// FilterItemModel.swift +// koin +// +// Created by 홍기정 on 8/27/26. +// + +import Foundation + +struct FilterItemModel { + + let title: String + var isSelected: Bool + + init( + title: String, + isSelected: Bool = false + ) { + self.title = title + self.isSelected = isSelected + } +} diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift b/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift new file mode 100644 index 00000000..a3ce07f6 --- /dev/null +++ b/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionView.swift @@ -0,0 +1,122 @@ +// +// FilterGroupCollectionView.swift +// koin +// +// Created by 홍기정 on 8/27/26. +// + +import UIKit +import Combine + +final class FilterGroupCollectionView: UICollectionView { + + // MARK: - Publisher + let itemTappedPublisher = PassthroughSubject() + + // MARK: - Properties + private(set) var filterGroup: FilterGroupModel + + // MARK: - Initializer + init(filterGroup: FilterGroupModel) { + self.filterGroup = filterGroup + super.init( + frame: .zero, + collectionViewLayout: LeftAlignedFlowLayout() + ) + commonInit() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func update(filterGroup: FilterGroupModel, changed indexPaths: [IndexPath]) { + self.filterGroup = filterGroup + reconfigureItems(at: indexPaths) + } + + // MARK: - Override + override var intrinsicContentSize: CGSize { + CGSize( + width: UIView.noIntrinsicMetric, + height: collectionViewLayout.collectionViewContentSize.height + ) + } + override func layoutSubviews() { + super.layoutSubviews() + if bounds.height != collectionViewLayout.collectionViewContentSize.height { + invalidateIntrinsicContentSize() + } + } +} + +extension FilterGroupCollectionView { + private func commonInit() { + isScrollEnabled = false + delegate = self + dataSource = self + register( + FilterGroupCollectionViewCell.self, + forCellWithReuseIdentifier: FilterGroupCollectionViewCell.identifier + ) + } +} + +extension FilterGroupCollectionView: UICollectionViewDelegateFlowLayout { + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + collectionView.deselectItem(at: indexPath, animated: false) + itemTappedPublisher.send(indexPath.row) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let title = filterGroup.items[indexPath.row].title + let titleWidth = (title as NSString).size( + withAttributes: [.font: UIFont.appFont(.pretendardSemiBold, size: 14)] + ).width + return CGSize( + width: ceil(titleWidth) + 12 * 2, + height: 34 + ) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + minimumLineSpacingForSectionAt section: Int + ) -> CGFloat { + 8 + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + minimumInteritemSpacingForSectionAt section: Int + ) -> CGFloat { + 12 + } +} + +extension FilterGroupCollectionView: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return filterGroup.items.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: FilterGroupCollectionViewCell.identifier, + for: indexPath + ) as? FilterGroupCollectionViewCell else { + return UICollectionViewCell() + } + let index = indexPath.row + cell.configure(item: filterGroup.items[index]) + return cell + } +} diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift b/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift new file mode 100644 index 00000000..40a567a6 --- /dev/null +++ b/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupCollectionView/FilterGroupCollectionViewCell.swift @@ -0,0 +1,65 @@ +// +// FilterGroupCollectionViewCell.swift +// koin +// +// Created by 홍기정 on 8/27/26. +// + +import UIKit +import SnapKit +import Then + +final class FilterGroupCollectionViewCell: UICollectionViewCell { + + // MARK: - UI Components + private let titleLabel = UILabel() + + // MARK: - Initializer + override init(frame: CGRect) { + super.init(frame: frame) + configureView() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + func configure(item model: FilterItemModel) { + titleLabel.text = model.title + titleLabel.textColor = model.isSelected ? .appColor(.new500) : .appColor(.neutral500) + contentView.layer.borderColor = model.isSelected + ? UIColor.appColor(.new500).cgColor + : UIColor.appColor(.neutral300).cgColor + } +} + +extension FilterGroupCollectionViewCell { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + titleLabel.do { + $0.font = .appFont(.pretendardSemiBold, size: 14) + $0.textAlignment = .center + } + contentView.do { + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 17 + $0.clipsToBounds = true + } + } + + private func setUpLayouts() { + contentView.addSubview(titleLabel) + } + + private func setUpConstraints() { + titleLabel.snp.makeConstraints { + $0.top.bottom.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(12) + } + } +} diff --git a/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupView.swift b/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupView.swift new file mode 100644 index 00000000..2db944cb --- /dev/null +++ b/Koin/Presentation/Shared/FilterBottomSheet/Subviews/FilterGroupView.swift @@ -0,0 +1,97 @@ +// +// FilterGroupView.swift +// koin +// +// Created by 홍기정 on 8/27/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class FilterGroupView: UIView { + + // MARK: - Publisher + var itemTappedPublisher: AnyPublisher { + filterGroupCollectionView.itemTappedPublisher.eraseToAnyPublisher() + } + + // MARK: - UI Components + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + private let filterGroupCollectionView: FilterGroupCollectionView + private let separatorView = UIView() + + // MARK: - Initializer + init(filterGroup: FilterGroupModel) { + self.filterGroupCollectionView = FilterGroupCollectionView(filterGroup: filterGroup) + super.init(frame: .zero) + + titleLabel.text = filterGroup.title + descriptionLabel.text = filterGroup.description + configureView() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func update(filterGroup: FilterGroupModel, changed: [IndexPath]) { + filterGroupCollectionView.update(filterGroup: filterGroup, changed: changed) + } +} + +extension FilterGroupView { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + titleLabel.do { + $0.font = UIFont.appFont(.pretendardSemiBold, size: 16) + $0.textColor = UIColor.appColor(.neutral800) + } + descriptionLabel.do { + $0.font = UIFont.appFont(.pretendardRegular, size: 12) + $0.textColor = UIColor.appColor(.neutral500) + } + separatorView.do { + $0.backgroundColor = UIColor.appColor(.neutral200) + } + } + + private func setUpLayouts() { + [titleLabel, filterGroupCollectionView, separatorView].forEach { + addSubview($0) + } + if descriptionLabel.text != nil { + addSubview(descriptionLabel) + } + } + + private func setUpConstraints() { + titleLabel.snp.makeConstraints { + $0.height.equalTo(26) + $0.leading.top.equalToSuperview() + } + if descriptionLabel.text != nil { + descriptionLabel.snp.makeConstraints { + $0.height.equalTo(19) + $0.centerY.equalTo(titleLabel) + $0.leading.equalTo(titleLabel.snp.trailing).offset(8) + } + } + filterGroupCollectionView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview() + } + separatorView.snp.makeConstraints { + $0.height.equalTo(1) + $0.top.equalTo(filterGroupCollectionView.snp.bottom).offset(12) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/Koin/Presentation/Shared/Notification/Models/NotificationRowModel.swift b/Koin/Presentation/Shared/Notification/Models/NotificationRowModel.swift new file mode 100644 index 00000000..90ca3160 --- /dev/null +++ b/Koin/Presentation/Shared/Notification/Models/NotificationRowModel.swift @@ -0,0 +1,15 @@ +// +// NotificationRowModel.swift +// koin +// +// Created by 홍기정 on 8/19/26. +// + +struct NotificationRowModel { + let id: String + var isRead: Bool + let icon: ImageAsset + let title: String + let content: String + let dateText: String +} diff --git a/Koin/Presentation/Home/Notification/Subviews/NotificationEmptyBackgroundView.swift b/Koin/Presentation/Shared/Notification/Views/NotificationEmptyView.swift similarity index 92% rename from Koin/Presentation/Home/Notification/Subviews/NotificationEmptyBackgroundView.swift rename to Koin/Presentation/Shared/Notification/Views/NotificationEmptyView.swift index f0c2525f..934ea7b9 100644 --- a/Koin/Presentation/Home/Notification/Subviews/NotificationEmptyBackgroundView.swift +++ b/Koin/Presentation/Shared/Notification/Views/NotificationEmptyView.swift @@ -1,5 +1,5 @@ // -// NotificationEmptyBackgroundView.swift +// NotificationEmptyView.swift // koin // // Created by 홍기정 on 6/3/26. @@ -9,7 +9,7 @@ import UIKit import SnapKit import Then -final class NotificationEmptyBackgroundView: UIView { +final class NotificationEmptyView: UIView { // MARK: - UI Components private let layoutGuide = UILayoutGuide() @@ -27,7 +27,7 @@ final class NotificationEmptyBackgroundView: UIView { } } -extension NotificationEmptyBackgroundView { +extension NotificationEmptyView { private func configureView() { setUpStyles() diff --git a/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift b/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift new file mode 100644 index 00000000..c29ababb --- /dev/null +++ b/Koin/Presentation/Shared/Notification/Views/NotificationListView.swift @@ -0,0 +1,142 @@ +// +// NotificationListView.swift +// koin +// +// Created by 홍기정 on 8/19/26. +// + +import UIKit +import Combine +import SnapKit +import Then + +final class NotificationListView: UIView { + + // MARK: - Properties + let refreshPublisher = PassthroughSubject() + let itemTappedPublisher = PassthroughSubject() + let deletePublisher = PassthroughSubject() + private var subscriptions = Set() + + // MARK: - UI Components + private let tableView = NotificationTableView() + private let refreshControl = UIRefreshControl() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let emptyView = NotificationEmptyView() + + // MARK: - Initialization + override init(frame: CGRect) { + super.init(frame: frame) + configureView() + bind() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func startLoading() { + loadingIndicator.startAnimating() + } + + func update(items: [NotificationRowModel]) { + tableView.update(notifications: items) + updateStateViews(isEmpty: items.isEmpty) + } + + func markAllAsRead() { + tableView.markAllAsRead() + } + + func deleteAll() { + tableView.deleteAll() + updateStateViews(isEmpty: true) + } +} + +// MARK: - Bind + +private extension NotificationListView { + private func bind() { + tableView.tapNotificationPublisher + .sink { [weak self] id in + self?.itemTappedPublisher.send(id) + } + .store(in: &subscriptions) + + tableView.deletePublisher + .sink { [weak self] id in + guard let self else { return } + updateStateViews(isEmpty: tableView.isEmpty) + deletePublisher.send(id) + } + .store(in: &subscriptions) + } +} + +// MARK: - State + +private extension NotificationListView { + private func updateStateViews(isEmpty: Bool) { + loadingIndicator.stopAnimating() + refreshControl.endRefreshing() + + UIView.animate( + withDuration: 0.2, + delay: 0, + options: [.curveEaseInOut, .beginFromCurrentState] + ) { [weak self] in + self?.tableView.backgroundView?.alpha = isEmpty ? 1 : 0 + } + } +} + +// MARK: - Configure + +private extension NotificationListView { + private func configureView() { + setUpAddTargets() + setUpStyles() + setUpLayouts() + setUpConstraints() + } + + private func setUpStyles() { + backgroundColor = .appColor(.neutral0) + + loadingIndicator.do { + $0.hidesWhenStopped = true + } + + tableView.do { + $0.refreshControl = refreshControl + $0.backgroundView = emptyView + } + + emptyView.do { + $0.alpha = 0 + } + } + + private func setUpLayouts() { + [tableView, loadingIndicator].forEach { + addSubview($0) + } + } + private func setUpConstraints() { + tableView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + private func setUpAddTargets() { + refreshControl.addTarget(self, action: #selector(didPullToRefresh), for: .valueChanged) + } + + @objc private func didPullToRefresh() { + refreshPublisher.send() + } +} diff --git a/Koin/Presentation/Home/Notification/Subviews/NotificationPopUpViewController.swift b/Koin/Presentation/Shared/Notification/Views/NotificationPopUpViewController.swift similarity index 98% rename from Koin/Presentation/Home/Notification/Subviews/NotificationPopUpViewController.swift rename to Koin/Presentation/Shared/Notification/Views/NotificationPopUpViewController.swift index 05f6bc1c..44f14c71 100644 --- a/Koin/Presentation/Home/Notification/Subviews/NotificationPopUpViewController.swift +++ b/Koin/Presentation/Shared/Notification/Views/NotificationPopUpViewController.swift @@ -18,8 +18,8 @@ final class NotificationPopUpViewController: UIViewController { private let deleteAllButton = UIButton() // MARK: - Properties - @objc private let markAllAsRead: ()->Void - @objc private let deleteAll: ()->Void + private let markAllAsRead: ()->Void + private let deleteAll: ()->Void private var minimizedTransform: CGAffineTransform { let scale = 0.4 diff --git a/Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationFooterView.swift b/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationFooterView.swift similarity index 98% rename from Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationFooterView.swift rename to Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationFooterView.swift index 6b46ab2d..a6ac8bed 100644 --- a/Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationFooterView.swift +++ b/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationFooterView.swift @@ -11,6 +11,7 @@ import Then final class NotificationFooterView: UIView { + // MARK: - UI Components private let label = UILabel() override init(frame: CGRect) { diff --git a/Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationTableView.swift b/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableView.swift similarity index 95% rename from Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationTableView.swift rename to Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableView.swift index 28541a1d..e08a3c77 100644 --- a/Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationTableView.swift +++ b/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableView.swift @@ -20,13 +20,13 @@ final class NotificationTableView: UITableView { // MARK: - Publisher let deletePublisher = PassthroughSubject() - let tapNotificationPublisher = PassthroughSubject() + let tapNotificationPublisher = PassthroughSubject() // MARK: - UI Components private let realFooterView = NotificationFooterView() // MARK: - Properties - private var notifications: [NotificationItem] = [] + private var notifications: [NotificationRowModel] = [] var isEmpty: Bool { notifications.isEmpty } @@ -43,7 +43,7 @@ final class NotificationTableView: UITableView { } // MARK: - Public - func update(notifications: [NotificationItem]) { + func update(notifications: [NotificationRowModel]) { performBatchUpdates { self.notifications = notifications recalculateFooterHeightIfNeeded() @@ -173,8 +173,9 @@ extension NotificationTableView: UITableViewDelegate { guard notifications.indices.contains(indexPath.row) else { return } - tapNotificationPublisher.send(notifications[indexPath.row]) - didSelectNotification(id: notifications[indexPath.row].id) + let id = notifications[indexPath.row].id + tapNotificationPublisher.send(id) + didSelectNotification(id: id) } func tableView( diff --git a/Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationTableViewCell.swift b/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableViewCell.swift similarity index 98% rename from Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationTableViewCell.swift rename to Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableViewCell.swift index ca19ef7c..fea8fb9e 100644 --- a/Koin/Presentation/Home/Notification/Subviews/NotificationTableView/NotificationTableViewCell.swift +++ b/Koin/Presentation/Shared/Notification/Views/NotificationTableView/NotificationTableViewCell.swift @@ -27,7 +27,7 @@ final class NotificationTableViewCell: UITableViewCell { } // MARK: - Public - func configure(item: NotificationItem) { + func configure(item: NotificationRowModel) { iconImageView.image = .appImage(asset: item.icon)?.withRenderingMode(.alwaysTemplate) titleLabel.text = item.title contentLabel.text = item.content diff --git a/Koin/Presentation/Shop/ShopReview/BackButtonPopUpViewController.swift b/Koin/Presentation/Shop/ShopReview/BackButtonPopUpViewController.swift deleted file mode 100644 index d58263a5..00000000 --- a/Koin/Presentation/Shop/ShopReview/BackButtonPopUpViewController.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// CustomAlertViewController.swift -// koin -// -// Created by 김성민 on 11/4/25. -// - -import UIKit -import SnapKit - -final class BackButtonPopUpViewController: UIViewController { - var onStop: (() -> Void)? - - - private let dimView = UIView().then { - $0.backgroundColor = UIColor.black.withAlphaComponent(0.7) - } - - private let card = UIView().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.cornerRadius = 8 - $0.layer.masksToBounds = true - } - - private let titleLabel = UILabel().then { - $0.text = "리뷰 수정을 그만하시겠어요?" - $0.textAlignment = .center - $0.textColor = UIColor.appColor(.neutral600) - $0.font = UIFont.setFont(.body2) - } - - private let stopButton = UIButton(type: .system).then { - var config = UIButton.Configuration.plain() - config.baseForegroundColor = UIColor.appColor(.neutral600) - config.background.backgroundColor = UIColor.appColor(.neutral0) - config.background.strokeColor = UIColor.appColor(.neutral400) - config.background.strokeWidth = 1 - config.attributedTitle = AttributedString( - "그만하기", - attributes: .init([.font: UIFont.setFont(.body2Strong)]) - ) - config.contentInsets = .init(top: 12, leading: 31.25, bottom: 12, trailing: 31.25) - $0.configuration = config - } - - private let keepButton = UIButton(type: .system).then { - var config = UIButton.Configuration.plain() - config.baseForegroundColor = UIColor.appColor(.neutral0) - config.background.backgroundColor = UIColor.appColor(.new500) - config.attributedTitle = AttributedString( - "계속쓰기", - attributes: .init([.font: UIFont.setFont(.body2Strong)]) - ) - config.contentInsets = .init(top: 12, leading: 31.25, bottom: 12, trailing: 31.25) - $0.configuration = config - } - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - stopButton.addTarget(self, action: #selector(stopButtonTapped), for: .touchUpInside) - keepButton.addTarget(self, action: #selector(keepButtonTapped), for: .touchUpInside) - } - - - private func configureView() { - setLayouts() - setUpConstraints() - } - - private func setLayouts() { - view.backgroundColor = .clear - view.addSubview(dimView) - view.addSubview(card) - [titleLabel, stopButton, keepButton].forEach{ - card.addSubview($0) - } - } - - private func setUpConstraints() { - dimView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - card.snp.makeConstraints { - $0.center.equalToSuperview() - $0.height.equalTo(144) - $0.width.equalTo(301) - } - titleLabel.snp.makeConstraints{ - $0.centerX.equalToSuperview() - $0.top.equalToSuperview().offset(24) - } - stopButton.snp.makeConstraints{ - $0.height.equalTo(48) - $0.leading.equalToSuperview().offset(32) - $0.top.equalTo(titleLabel.snp.bottom).offset(24) - } - keepButton.snp.makeConstraints{ - $0.size.equalTo(stopButton) - $0.leading.equalTo(stopButton.snp.trailing).offset(8) - $0.centerY.equalTo(stopButton) - } - } - - - - @objc private func stopButtonTapped() { - dismiss(animated: false) { [weak self] in self?.onStop?() } - } - - @objc private func keepButtonTapped() { - dismiss(animated: false) - } -} diff --git a/Koin/Presentation/Shop/ShopReview/ShopReviewViewController.swift b/Koin/Presentation/Shop/ShopReview/ShopReviewViewController.swift index a3f7ae22..b4a7c847 100644 --- a/Koin/Presentation/Shop/ShopReview/ShopReviewViewController.swift +++ b/Koin/Presentation/Shop/ShopReview/ShopReviewViewController.swift @@ -417,14 +417,19 @@ extension ShopReviewViewController { guard presentedViewController == nil, navigationController?.transitionCoordinator == nil else { return } - let viewController = BackButtonPopUpViewController() - viewController.modalPresentationStyle = .overFullScreen - viewController.modalTransitionStyle = .crossDissolve - viewController.onStop = { [weak self, weak viewController] in - viewController?.dismiss(animated: false) - self?.navigationController?.popViewController(animated: true) - } - present(viewController, animated: false) + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .singleTitle(text: "리뷰 수정을 그만하시겠어요?"), + button: .buttons( + leftButtonTitle: "그만하기", + leftButtonAction: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }, + rightButtonTitle: "계속쓰기", + rightButtonAction: {} + ) + )) + present(modalViewController, animated: true) } private func setNavigationItem() { @@ -667,4 +672,3 @@ extension UIResponder { Static.responder = self } } - diff --git a/Koin/Presentation/Shop/ShopReviewList/ReviewListViewController.swift b/Koin/Presentation/Shop/ShopReviewList/ReviewListViewController.swift index b3646657..dd7bb09b 100644 --- a/Koin/Presentation/Shop/ShopReviewList/ReviewListViewController.swift +++ b/Koin/Presentation/Shop/ShopReviewList/ReviewListViewController.swift @@ -66,23 +66,6 @@ final class ReviewListViewController: UIViewController { $0.hidesWhenStopped = true } - // MARK: - Modal ViewControllers - - private lazy var reviewWriteLoginModalViewController = ReviewLoginModalViewController(message: "작성").then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - - private lazy var reviewReportLoginModalViewController = ReviewLoginModalViewController(message: "신고").then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - - private lazy var deleteReviewModalViewController = DeleteReviewModalViewController().then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - // MARK: - Initialize init(viewModel: ReviewListViewModel) { @@ -120,7 +103,6 @@ final class ReviewListViewController: UIViewController { private func bind() { bindViewModel() bindCollectionView() - bindModalViewControllers() } } @@ -144,10 +126,10 @@ extension ReviewListViewController { switch output { case .showWriteReviewLoginModal: - self.present(self.reviewWriteLoginModalViewController, animated: true) + self.presentReviewLoginModal(message: "작성") case .showReportReviewLoginModal: - self.present(self.reviewReportLoginModalViewController, animated: true) + self.presentReviewLoginModal(message: "신고") case .showMyReviewFilterError: self.nonReviewListView.isHidden = false @@ -235,66 +217,6 @@ extension ReviewListViewController { .store(in: &cancellables) } - private func bindModalViewControllers() { - reviewWriteLoginModalViewController.loginButtonPublisher - .sink { [weak self] in - guard let self else { return } - self.inputSubject.send(.logEvent( - EventParameter.EventLabel.Business.loginPrompt, - .click, - "리뷰 작성 팝업" - )) - self.showLoginScreen() - } - .store(in: &cancellables) - - reviewWriteLoginModalViewController.cancelButtonPublisher - .sink { [weak self] in - guard let self else { return } - self.inputSubject.send(.logEvent( - EventParameter.EventLabel.Business.shopDetailViewReviewWriteCancel, - .click, - self.viewModel.getShopName() - )) - } - .store(in: &cancellables) - - deleteReviewModalViewController.deleteButtonPublisher - .sink { [weak self] in - guard let self else { return } - self.inputSubject.send(.logEvent( - EventParameter.EventLabel.Business.shopDetailViewReviewDeleteDone, - .click, - "O" - )) - self.deleteReview() - } - .store(in: &cancellables) - - deleteReviewModalViewController.cancelButtonPublisher - .sink { [weak self] in - guard let self else { return } - self.inputSubject.send(.logEvent( - EventParameter.EventLabel.Business.shopDetailViewReviewDeleteDone, - .click, - "X" - )) - } - .store(in: &cancellables) - - reviewReportLoginModalViewController.loginButtonPublisher - .sink { [weak self] in - guard let self else { return } - self.inputSubject.send(.logEvent( - EventParameter.EventLabel.Business.loginPrompt, - .click, - "리뷰 신고 팝업" - )) - self.showLoginScreen() - } - .store(in: &cancellables) - } - private func setAddTarget() { writeReviewButton.addTarget(self, action: #selector(writeReviewButtonTapped), for: .touchUpInside) } @@ -388,7 +310,62 @@ extension ReviewListViewController { .click, viewModel.getShopName() )) - present(deleteReviewModalViewController, animated: true) + + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .singleTitle(text: "삭제한 리뷰는 되돌릴 수 없습니다.\n삭제 하시겠습니까?"), + button: .buttons( + leftButtonTitle: "취소하기", + leftButtonAction: { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Business.shopDetailViewReviewDeleteDone, .click, "X")) + }, + rightButtonTitle: "삭제하기", + rightButtonAction: { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Business.shopDetailViewReviewDeleteDone, .click, "O")) + self?.deleteReview() + } + ) + )) + present(modalViewController, animated: true) + } + + private func presentReviewLoginModal(message: String) { + let mainText: String + let subText: String + + switch message { + case "작성": + mainText = "리뷰를 작성하기 위해\n로그인이 필요해요." + subText = "리뷰 작성은 회원만 사용 가능합니다." + + case "신고": + mainText = "리뷰를 신고하기 위해\n로그인이 필요해요." + subText = "리뷰 신고는 회원만 사용 가능합니다." + + default: + return + } + + let modalViewController = KoinModalViewController(configuration: .init( + appearance: .new, + content: .titles( + mainTitleText: mainText, + subTitleText: subText + ), + button: .buttons( + leftButtonTitle: "닫기", + leftButtonAction: { [weak self] in + guard message == "작성", let self else { return } + inputSubject.send(.logEvent(EventParameter.EventLabel.Business.shopDetailViewReviewWriteCancel, .click, viewModel.getShopName())) + }, + rightButtonTitle: "로그인하기", + rightButtonAction: { [weak self] in + self?.inputSubject.send(.logEvent(EventParameter.EventLabel.Business.loginPrompt, .click, "리뷰 \(message) 팝업")) + self?.showLoginScreen() + } + ) + )) + present(modalViewController, animated: true) } private func deleteReview() { diff --git a/Koin/Presentation/Shop/ShopReviewList/SubViews/DeleteReviewModalViewController.swift b/Koin/Presentation/Shop/ShopReviewList/SubViews/DeleteReviewModalViewController.swift deleted file mode 100644 index 98f48bd7..00000000 --- a/Koin/Presentation/Shop/ShopReviewList/SubViews/DeleteReviewModalViewController.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// DeleteReviewModalViewController.swift -// koin -// -// Created by 김나훈 on 8/13/24. -// - -import Combine -import UIKit -import SnapKit - -final class DeleteReviewModalViewController: UIViewController { - - // MARK: - Properties - let deleteButtonPublisher = PassthroughSubject() - let cancelButtonPublisher = PassthroughSubject() - - // MARK: - UI Components - private let containerView = UIView().then { - $0.backgroundColor = .white - $0.layer.cornerRadius = 8 - $0.layer.masksToBounds = true - } - - private let reviewDeleteInfoLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 15) - $0.textColor = UIColor.appColor(.neutral600) - $0.numberOfLines = 0 - $0.textAlignment = .center - - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - paragraphStyle.alignment = .center - - let text = "삭제한 리뷰는 되돌릴 수 없습니다.\n삭제 하시겠습니까?" - let attributedString = NSMutableAttributedString(string: text) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - - $0.attributedText = attributedString - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - private let deleteButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.new500) - $0.setTitle("삭제하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - // MARK: - Life Cycle - override func viewDidLoad() { - super.viewDidLoad() - configureView() - setAddTarget() - } - - private func setAddTarget() { - deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } -} - -extension DeleteReviewModalViewController { - @objc private func closeButtonTapped() { - cancelButtonPublisher.send() - dismiss(animated: true, completion: nil) - } - - @objc private func deleteButtonTapped() { - dismiss(animated: true, completion: nil) - deleteButtonPublisher.send(()) - } -} - -// MARK: - UI Functions -extension DeleteReviewModalViewController { - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - - [reviewDeleteInfoLabel, closeButton, deleteButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { - $0.centerX.equalTo(view.snp.centerX) - $0.centerY.equalTo(view.snp.centerY) - $0.width.equalTo(301) - $0.height.equalTo(168) - } - - reviewDeleteInfoLabel.snp.makeConstraints { - $0.top.equalTo(containerView.snp.top).offset(24) - $0.centerX.equalTo(containerView.snp.centerX) - } - - closeButton.snp.makeConstraints { - $0.top.equalTo(reviewDeleteInfoLabel.snp.bottom).offset(24) - $0.trailing.equalTo(containerView.snp.centerX).offset(-4) - $0.width.equalTo(114.5) - $0.height.equalTo(48) - } - - deleteButton.snp.makeConstraints { - $0.top.equalTo(reviewDeleteInfoLabel.snp.bottom).offset(24) - $0.leading.equalTo(containerView.snp.centerX).offset(4) - $0.width.equalTo(114.5) - $0.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } -} diff --git a/Koin/Presentation/Shop/ShopReviewList/SubViews/ReviewListCollectionView/ImageDropDownCell.swift b/Koin/Presentation/Shop/ShopReviewList/SubViews/ReviewListCollectionView/ImageDropDownCell.swift deleted file mode 100644 index c7879cad..00000000 --- a/Koin/Presentation/Shop/ShopReviewList/SubViews/ReviewListCollectionView/ImageDropDownCell.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ImageDropDownCell.swift -// koin -// -// Created by 김나훈 on 8/12/24. -// - -import Then -import UIKit - -final class ImageDropDownCell: DropDownCell { - - - private let dropDownLabel = UILabel().then { - $0.textColor = UIColor.appColor(.neutral800) - $0.font = UIFont.appFont(.pretendardRegular, size: 14) - } - - private let dropDownImageView = UIImageView().then { _ in - } - - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - setupView() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(text: String, image: UIImage?) { - dropDownLabel.text = text - dropDownImageView.image = image - } - - private func setupView() { - [dropDownLabel, dropDownImageView].forEach { - contentView.addSubview($0) - } - dropDownLabel.snp.makeConstraints { make in - make.centerY.equalTo(contentView.snp.centerY) - make.leading.equalTo(contentView.snp.leading).offset(10) - } - dropDownImageView.snp.makeConstraints { make in - make.centerY.equalTo(contentView.snp.centerY) - make.leading.equalTo(dropDownLabel.snp.trailing).offset(5) - make.width.equalTo(16) - make.height.equalTo(16) - } - } -} diff --git a/Koin/Presentation/Shop/ShopReviewList/SubViews/ReviewLoginModalViewController.swift b/Koin/Presentation/Shop/ShopReviewList/SubViews/ReviewLoginModalViewController.swift deleted file mode 100644 index a0421b61..00000000 --- a/Koin/Presentation/Shop/ShopReviewList/SubViews/ReviewLoginModalViewController.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// ReviewLoginModalViewController.swift -// koin -// -// Created by 김나훈 on 8/13/24. -// - - -import Combine -import UIKit - -final class ReviewLoginModalViewController: UIViewController { - - // MARK: - Properties - - private let message: String - - // MARK: - Publisher - - let loginButtonPublisher = PassthroughSubject() - let cancelButtonPublisher = PassthroughSubject() - - // MARK: - UI Components - - private lazy var messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 18) - $0.textColor = UIColor.appColor(.neutral600) - $0.numberOfLines = 2 - $0.textAlignment = .center - } - - private lazy var subMessageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 14) - $0.textColor = UIColor.appColor(.neutral500) - $0.numberOfLines = 1 - $0.textAlignment = .center - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral400).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("닫기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - private let loginButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.new500) - $0.setTitle("로그인하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - private let containerView = UIView().then { - $0.backgroundColor = .white - $0.layer.cornerRadius = 6 - $0.layer.masksToBounds = true - } - - // MARK: - Initializer - - init(message: String) { - self.message = message - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Life Cycles - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - configureMessages() - setAddTarget() - } - - private func setAddTarget() { - loginButton.addTarget(self, action: #selector(loginButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } - - private func configureMessages() { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 8 - - let mainText: String - let subText: String - - switch message { - case "작성": - mainText = "리뷰를 작성하기 위해\n로그인이 필요해요." - subText = "리뷰 작성은 회원만 사용 가능합니다." - - case "신고": - mainText = "리뷰를 신고하기 위해\n로그인이 필요해요." - subText = "리뷰 신고는 회원만 사용 가능합니다." - - default: - mainText = "리뷰를 작성하기 위해\n로그인이 필요해요." - subText = "리뷰 작성은 회원만 사용 가능합니다." - } - - let mainAttributedString = NSMutableAttributedString( - string: mainText, - attributes: [.paragraphStyle: paragraphStyle] - ) - messageLabel.attributedText = mainAttributedString - - let subParagraphStyle = NSMutableParagraphStyle() - subParagraphStyle.lineSpacing = 6 - let subAttributedString = NSMutableAttributedString( - string: subText, - attributes: [.paragraphStyle: subParagraphStyle] - ) - subMessageLabel.attributedText = subAttributedString - } - - @objc private func closeButtonTapped() { - dismiss(animated: true, completion: nil) - cancelButtonPublisher.send() - } - - @objc private func loginButtonTapped() { - dismiss(animated: true, completion: nil) - loginButtonPublisher.send(()) - } -} - -// MARK: - UI Functions - -extension ReviewLoginModalViewController { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, subMessageLabel, closeButton, loginButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(301) - make.height.equalTo(208) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(24) - make.centerX.equalTo(containerView.snp.centerX) - } - subMessageLabel.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(8) - make.centerX.equalTo(containerView.snp.centerX) - } - closeButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-4) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - loginButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(4) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } -} diff --git a/Koin/Presentation/TimeTable/FrameList/FrameListViewController.swift b/Koin/Presentation/TimeTable/FrameList/FrameListViewController.swift index c30c5323..196d8195 100644 --- a/Koin/Presentation/TimeTable/FrameList/FrameListViewController.swift +++ b/Koin/Presentation/TimeTable/FrameList/FrameListViewController.swift @@ -20,10 +20,6 @@ final class FrameListViewController: UIViewController { // MARK: - UI Components private let tableView = UITableView(frame: .zero, style: .plain) - private let modifyFrameModalViewController: ModifyFrameModalViewController = ModifyFrameModalViewController(width: 327, height: 216) - - private let modifySemesterModalViewController: ModifySemesterModalViewController = ModifySemesterModalViewController(width: 327, height: 232) - private let emptyFrameLabel = UILabel().then { $0.text = "우측 상단의 버튼으로 학기를 추가해\n시간표 기능을 사용해 보세요!" $0.textAlignment = .center @@ -32,12 +28,6 @@ final class FrameListViewController: UIViewController { $0.font = UIFont.appFont(.pretendardMedium, size: 13) } - private let deleteSemesterModalViewController = DeleteSemesterModalViewController().then { _ in - } - - private let deleteFrameModalViewController = DeleteFrameModalViewController().then { _ in - } - // MARK: - Initialization func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { return .leastNormalMagnitude // 기본 여백 제거 @@ -96,33 +86,6 @@ final class FrameListViewController: UIViewController { } .store(in: &subscriptions) - modifyFrameModalViewController.deleteButtonPublisher.sink(receiveValue: { [weak self] frame in - guard let self = self else { return } - deleteFrameModalViewController.configure(frame: frame) - present(deleteFrameModalViewController, animated: false) - }).store(in: &subscriptions) - - modifyFrameModalViewController.saveButtonPublisher.sink(receiveValue: { [weak self] frame in - self?.inputSubject.send(.modifyFrame(frame)) - - }).store(in: &subscriptions) - - modifySemesterModalViewController.applyButtonPublisher.sink { [weak self] addedSemester, removedSemester in - guard let self = self else { return } - inputSubject.send(.modifySemester(addedSemester, [])) - if !removedSemester.isEmpty { - deleteSemesterModalViewController.setSemesters(semesters: removedSemester) - present(deleteSemesterModalViewController, animated: false) - } - }.store(in: &subscriptions) - - deleteSemesterModalViewController.deleteButtonPublisher.sink { [weak self] semesters in - self?.inputSubject.send(.modifySemester([], semesters)) - }.store(in: &subscriptions) - - deleteFrameModalViewController.deleteButtonPublisher.sink { [weak self] frame in - self?.inputSubject.send(.deleteFrame(frame)) - }.store(in: &subscriptions) } } @@ -130,10 +93,13 @@ final class FrameListViewController: UIViewController { extension FrameListViewController: TimetableCellDelegate { @objc private func modifySemesterButtonTapped() { - - modifySemesterModalViewController.configre(frameList: viewModel.frameData) - self.present(modifySemesterModalViewController, animated: true) - + let modifySemesterModalViewController = ModifySemesterModalViewController( + onApplyButtonTapped: { [weak self] addedSemesters, removedSemesters in + self?.applySemesterChanges(addedSemesters: addedSemesters, removedSemesters: removedSemesters) + } + ) + modifySemesterModalViewController.configure(frameList: viewModel.frameData) + present(modifySemesterModalViewController, animated: true) } @objc private func addTimetableTapped(_ sender: UIButton) { @@ -152,13 +118,34 @@ extension FrameListViewController: TimetableCellDelegate { let row = indexPath.row // 추가 동작 (예: 삭제 모달 띄우기) - let timetable = viewModel.frameData[section].frame[row] + let modifyFrameModalViewController = ModifyFrameModalViewController( + onDeleteButtonTapped: { [weak self] frame in + self?.presentDeleteFrameModal(frame: frame) + }, + onSaveButtonTapped: { [weak self] frame in + self?.inputSubject.send(.modifyFrame(frame)) + } + ) modifyFrameModalViewController.configure(frame: viewModel.frameData[section].frame[row]) - self.present(modifyFrameModalViewController, animated: true) - - + present(modifyFrameModalViewController, animated: true) + } + + private func applySemesterChanges(addedSemesters: [String], removedSemesters: [String]) { + inputSubject.send(.modifySemester(addedSemesters, [])) + guard !removedSemesters.isEmpty else { return } + + let deleteSemesterModalViewController = DeleteSemesterModalViewController(semesters: removedSemesters) { [weak self] semesters in + self?.inputSubject.send(.modifySemester([], semesters)) + } + present(deleteSemesterModalViewController, animated: false) + } + + private func presentDeleteFrameModal(frame: FrameDto) { + let deleteFrameModalViewController = DeleteFrameModalViewController(frame: frame) { [weak self] frame in + self?.inputSubject.send(.deleteFrame(frame)) + } + present(deleteFrameModalViewController, animated: true) } - } extension FrameListViewController: UITableViewDelegate, UITableViewDataSource { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { diff --git a/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteFrameModalViewController.swift b/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteFrameModalViewController.swift index 60d535a5..ed8c6da1 100644 --- a/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteFrameModalViewController.swift +++ b/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteFrameModalViewController.swift @@ -5,118 +5,55 @@ // Created by 김나훈 on 12/10/24. // -import Combine import UIKit -final class DeleteFrameModalViewController: UIViewController { - - let deleteButtonPublisher = PassthroughSubject() - private var frame: FrameDto? = nil - - private let messageLabel = UILabel().then { - $0.textColor = UIColor.appColor(.neutral800) - $0.font = UIFont.appFont(.pretendardMedium, size: 16) - } - - private let deleteButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.danger700) - $0.setTitle("삭제하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 14) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } +final class DeleteFrameModalViewController: KoinModalViewController { - private let cancelButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView = UIButton().then { - $0.backgroundColor = .white - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) - cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) - } - - @objc private func deleteButtonTapped() { - if let frame = frame { - deleteButtonPublisher.send(frame) - } - dismiss(animated: true, completion: nil) - } - @objc private func cancelButtonTapped() { - dismiss(animated: true, completion: nil) - } - - func configure(frame: FrameDto) { + // MARK: - Properties + private let onDeleteButtonTapped: (FrameDto) -> Void + private let frame: FrameDto + + // MARK: - Initializer + init( + frame: FrameDto, + onDeleteButtonTapped: @escaping (FrameDto) -> Void + ) { + self.onDeleteButtonTapped = onDeleteButtonTapped self.frame = frame - - let frameName = frame.timetableName - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - paragraphStyle.alignment = .center - let text = "\(frameName)\(frameName.hasFinalConsonant() ? "을":"를") 삭제하시겠어요?" - let attributedString = NSMutableAttributedString(string: text) - let range = (text as NSString).range(of: "삭제") - attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.danger700), range: range) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - messageLabel.attributedText = attributedString - } - -} -extension DeleteFrameModalViewController { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [deleteButton, messageLabel, cancelButton, deleteButton].forEach { - containerView.addSubview($0) - } + let attributedString = { + let frameName = frame.timetableName + let text = "\(frameName)\(frameName.hasFinalConsonant() ? "을":"를") 삭제하시겠어요?" + let attributedString = NSMutableAttributedString( + string: text, + attributes: [ + .font: UIFont.appFont(.pretendardMedium, size: 16), + .foregroundColor: UIColor.appColor(.neutral800) + ] + ) + let range = (text as NSString).range(of: "삭제") + attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.danger700), range: range) + return attributedString + }() + super.init(configuration: .init( + appearance: .destructive, + content: .attributedSingleTitle(title: attributedString), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "삭제하기", + rightButtonAction: {} + ) + )) + } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(327) - make.height.equalTo(216) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(47) - make.centerX.equalToSuperview() - } - cancelButton.snp.makeConstraints { make in - make.bottom.equalTo(containerView.snp.bottom).offset(-47) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(127.5) - make.height.equalTo(48) - } - deleteButton.snp.makeConstraints { make in - make.bottom.equalTo(containerView.snp.bottom).offset(-47) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(127.5) - make.height.equalTo(48) + + // MARK: - Override + override func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + onDeleteButtonTapped(frame) } } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } } diff --git a/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteSemesterModalViewController.swift b/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteSemesterModalViewController.swift index c6d8ffb0..c9389bae 100644 --- a/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteSemesterModalViewController.swift +++ b/Koin/Presentation/TimeTable/FrameList/SubViews/DeleteSemesterModalViewController.swift @@ -5,120 +5,54 @@ // Created by 김나훈 on 12/10/24. // -import Combine import UIKit -final class DeleteSemesterModalViewController: UIViewController { - - let deleteButtonPublisher = PassthroughSubject<[String], Never>() - private var semesters: [String]? = nil - - private let messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 16) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - $0.textColor = UIColor.appColor(.neutral800) - $0.numberOfLines = 3 - let text = "시간표가 작성되어 있는 학기가\n있어요. 해당 학기를 제외할 경우\n학기 내 시간표도 함께 삭제돼요." - let attributedString = NSMutableAttributedString(string: text) - let range = (text as NSString).range(of: "학기 내 시간표도 함께 삭제") - attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.danger700), range: range) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - $0.attributedText = attributedString - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let deleteButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.danger700) - $0.setTitle("삭제하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() - - override func viewDidLoad() { - super.viewDidLoad() - modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve - configureView() - deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } - - @objc private func closeButtonTapped() { - dismiss(animated: true, completion: nil) - } - - @objc private func deleteButtonTapped() { - if let semesters = semesters { - deleteButtonPublisher.send(semesters) - } - dismiss(animated: true, completion: nil) - } - - func setSemesters(semesters: [String]) { +final class DeleteSemesterModalViewController: KoinModalViewController { + + // MARK: - Properties + private let onDeleteButtonTapped: ([String]) -> Void + private let semesters: [String] + + // MARK: - Initializer + init( + semesters: [String], + onDeleteButtonTapped: @escaping ([String]) -> Void + ) { self.semesters = semesters - } -} + self.onDeleteButtonTapped = onDeleteButtonTapped -extension DeleteSemesterModalViewController { - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, closeButton, deleteButton].forEach { - containerView.addSubview($0) - } + let attributedString = { + let text = "시간표가 작성되어 있는 학기가\n있어요. 해당 학기를 제외할 경우\n학기 내 시간표도 함께 삭제돼요." + let attributedString = NSMutableAttributedString( + string: text, + attributes: [ + .font: UIFont.appFont(.pretendardMedium, size: 16), + .foregroundColor: UIColor.appColor(.neutral800) + ] + ) + let range = (text as NSString).range(of: "학기 내 시간표도 함께 삭제") + attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.danger700), range: range) + return attributedString + }() + super.init(configuration: .init( + appearance: .destructive, + content: .attributedSingleTitle(title: attributedString), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "삭제하기", + rightButtonAction: {} + ) + )) + } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(301) - make.height.equalTo(198) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(30) - make.centerX.equalTo(containerView.snp.centerX) - } - closeButton.snp.makeConstraints { make in - make.bottom.equalTo(containerView.snp.bottom).offset(-24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - deleteButton.snp.makeConstraints { make in - make.bottom.equalTo(containerView.snp.bottom).offset(-24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) + + // MARK: - Override + override func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + onDeleteButtonTapped(semesters) } } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } } diff --git a/Koin/Presentation/TimeTable/FrameList/SubViews/ModifyFrameModalViewController.swift b/Koin/Presentation/TimeTable/FrameList/SubViews/ModifyFrameModalViewController.swift index 5a8ddcca..34d88853 100644 --- a/Koin/Presentation/TimeTable/FrameList/SubViews/ModifyFrameModalViewController.swift +++ b/Koin/Presentation/TimeTable/FrameList/SubViews/ModifyFrameModalViewController.swift @@ -5,97 +5,97 @@ // Created by 김나훈 on 11/21/24. // -import Combine import UIKit -final class ModifyFrameModalViewController: UIViewController { - - let deleteButtonPublisher = PassthroughSubject() - let cancelButtonPublisher = PassthroughSubject() - let saveButtonPublisher = PassthroughSubject() - var containerWidth: CGFloat - var containerHeight: CGFloat - var frame: FrameDto = FrameDto(id: 0, timetableName: "", isMain: false) - - private let messageLabel = UILabel().then { - $0.text = "시간표 설정" - $0.font = UIFont.appFont(.pretendardRegular, size: 17) +final class ModifyFrameModalViewController: KoinModalViewController { + + // MARK: - Properties + private let onDeleteButtonTapped: (FrameDto) -> Void + private let onSaveButtonTapped: (FrameDto) -> Void + private var frame: FrameDto = FrameDto(id: 0, timetableName: "", isMain: false) + + // MARK: - UI Components + let containerView = UIView() + + let messageLabel = UILabel() + let deleteButton = UIButton() + let textField = UITextField() + + let checkButtonWrapperView = UIView() + let checkButton = UIButton() + let buttonTextLabel = UILabel() + + // MARK: - Initializer + init( + onDeleteButtonTapped: @escaping (FrameDto) -> Void, + onSaveButtonTapped: @escaping (FrameDto) -> Void + ) { + self.onDeleteButtonTapped = onDeleteButtonTapped + self.onSaveButtonTapped = onSaveButtonTapped + + super.init(configuration: .init( + appearance: .primary, + content: .custom(customView: containerView), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "저장", + rightButtonAction: {} //rightButtonAction + ), + layout: .init( + width: 327, + contentTopPadding: 12, + contentHorizontalPadding: 24, + paddingBetweenContentAndButton: 10, + buttonHorizontalPadding: 24, + buttonBottomPadding: 16 + ) + )) + } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - private let deleteButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.danger700) - $0.setTitle("삭제", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 14) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true + // MARK: - Life Cycle + override func viewDidLoad() { + super.viewDidLoad() + configureView() + setUpDelegate() + setUpAddTargets() } - private let textField = UITextField().then { - $0.backgroundColor = UIColor.appColor(.neutral100) + // MARK: - Public + func configure(frame: FrameDto) { + self.frame = frame + self.textField.attributedPlaceholder = NSAttributedString( + string: frame.timetableName, + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral500) + ]) + self.frame = frame + self.checkButton.setImage(UIImage.appImage(asset: frame.isMain ? .checkFill : .checkEmpty), for: .normal) } - private let checkButton = UIButton().then { _ in - } - private let buttonTextLabel = UILabel().then { - $0.text = "기본 시간표로 설정하기" - $0.font = UIFont.appFont(.pretendardMedium, size: 14) + // MARK: - Override + override func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + onSaveButtonTapped(frame) + } } +} - private let cancelButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let saveButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitle("저장", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() - - init(width: CGFloat, height: CGFloat) { - self.containerWidth = width - self.containerHeight = height - super.init(nibName: nil, bundle: nil) +extension ModifyFrameModalViewController { + private func setUpDelegate() { textField.delegate = self } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - configureView() +} + +extension ModifyFrameModalViewController { + private func setUpAddTargets() { textField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) - cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) checkButton.addTarget(self, action: #selector(checkButtonTapped), for: .touchUpInside) - saveButton.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside) - } - - func configure(frame: FrameDto) { - self.frame = frame - self.textField.text = frame.timetableName - self.frame = frame - self.checkButton.setImage(UIImage.appImage(asset: frame.isMain ? .checkFill : .checkEmpty), for: .normal) } @objc private func textFieldDidChange(_ textField: UITextField) { @@ -107,83 +107,95 @@ final class ModifyFrameModalViewController: UIViewController { frame.isMain.toggle() checkButton.setImage(UIImage.appImage(asset: frame.isMain ? .checkFill : .checkEmpty), for: .normal) } - @objc private func deleteButtonTapped() { - dismiss(animated: true, completion: nil) - deleteButtonPublisher.send(frame) - } - @objc private func cancelButtonTapped() { - dismiss(animated: true, completion: nil) - } - @objc private func saveButtonTapped() { - saveButtonPublisher.send(frame) - dismiss(animated: true, completion: nil) - } - override func textFieldShouldReturn(_ textField: UITextField) -> Bool { - textField.resignFirstResponder() // 키보드 내리기 - return true + @objc private func deleteButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + onDeleteButtonTapped(frame) } - + } } extension ModifyFrameModalViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) + private func setUpStyles() { + messageLabel.do { + $0.text = "시간표 설정" + $0.font = UIFont.appFont(.pretendardSemiBold, size: 16) + } + + deleteButton.do { + $0.backgroundColor = UIColor.appColor(.danger700) + $0.setTitle("삭제", for: .normal) + $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) + $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 14) + $0.layer.cornerRadius = 4 + $0.layer.masksToBounds = true + } + + textField.do { + $0.backgroundColor = UIColor.appColor(.neutral100) + $0.layer.borderColor = UIColor.appColor(.neutral300).cgColor + $0.layer.borderWidth = 1 + $0.layer.cornerRadius = 4 + $0.leftView = UIView(frame: .init(x: 0, y: 0, width: 16, height: 22)) + $0.leftViewMode = .always + $0.rightView = UIView(frame: .init(x: 0, y: 0, width: 16, height: 22)) + $0.rightViewMode = .always + $0.font = UIFont.appFont(.pretendardRegular, size: 14) + $0.textColor = UIColor.appColor(.neutral800) + } + + buttonTextLabel.do { + $0.text = "기본 시간표로 설정하기" + $0.font = UIFont.appFont(.pretendardMedium, size: 14) + } + } + private func setUpLayouts() { + [checkButton, buttonTextLabel].forEach { + checkButtonWrapperView.addSubview($0) } - [deleteButton, messageLabel, textField, checkButton, buttonTextLabel, cancelButton, saveButton].forEach { + [deleteButton, messageLabel, textField, checkButtonWrapperView].forEach { containerView.addSubview($0) } } - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(containerWidth) - make.height.equalTo(containerHeight) - } deleteButton.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(13) - make.leading.equalTo(containerView.snp.leading).offset(24) + make.top.leading.equalToSuperview() make.width.equalTo(60) make.height.equalTo(24) } messageLabel.snp.makeConstraints { make in - make.top.equalTo(deleteButton.snp.bottom) - make.centerX.equalTo(containerView.snp.centerX) + make.top.equalToSuperview().offset(14) + make.centerX.equalToSuperview() + make.height.equalTo(26) } textField.snp.makeConstraints { make in make.top.equalTo(messageLabel.snp.bottom).offset(14) - make.leading.equalTo(containerView.snp.leading).offset(24) - make.trailing.equalTo(containerView.snp.trailing).offset(-24) + make.leading.equalTo(containerView.snp.leading) + make.trailing.equalTo(containerView.snp.trailing) make.height.equalTo(46) } - checkButton.snp.makeConstraints { make in - make.top.equalTo(textField.snp.bottom).offset(8) - make.leading.equalTo(textField.snp.leading).offset(61) - make.width.height.equalTo(24) + checkButtonWrapperView.snp.makeConstraints { + $0.height.equalTo(24) + $0.centerX.equalToSuperview() + $0.top.equalTo(textField.snp.bottom).offset(10) + $0.bottom.equalToSuperview() } - buttonTextLabel.snp.makeConstraints { make in - make.centerY.equalTo(checkButton.snp.centerY) - make.leading.equalTo(checkButton.snp.trailing).offset(5) + + checkButton.snp.makeConstraints { + $0.top.leading.bottom.equalToSuperview() + $0.size.equalTo(24) } - cancelButton.snp.makeConstraints { make in - make.width.equalTo(135.5) - make.height.equalTo(48) - make.trailing.equalTo(containerView.snp.centerX).offset(-4) - make.bottom.equalTo(containerView.snp.bottom).offset(-16) + buttonTextLabel.snp.makeConstraints { + $0.centerY.equalTo(checkButton) + $0.leading.equalTo(checkButton.snp.trailing).offset(5) + $0.trailing.equalToSuperview() } - saveButton.snp.makeConstraints { make in - make.width.height.bottom.equalTo(cancelButton) - make.leading.equalTo(containerView.snp.centerX).offset(4) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) } } diff --git a/Koin/Presentation/TimeTable/FrameList/SubViews/ModifySemesterModalViewController.swift b/Koin/Presentation/TimeTable/FrameList/SubViews/ModifySemesterModalViewController.swift index 2702daaa..7ff6e2c5 100644 --- a/Koin/Presentation/TimeTable/FrameList/SubViews/ModifySemesterModalViewController.swift +++ b/Koin/Presentation/TimeTable/FrameList/SubViews/ModifySemesterModalViewController.swift @@ -5,192 +5,172 @@ // Created by 김나훈 on 11/21/24. // -import Combine import UIKit -final class ModifySemesterModalViewController: UIViewController { - - let applyButtonPublisher = PassthroughSubject<([String], [String]), Never>() - var frameList: [FrameData] = [] - var containerWidth: CGFloat - var containerHeight: CGFloat - +final class ModifySemesterModalViewController: KoinModalViewController { + + private enum Layout { + static let yearButtonWidth: CGFloat = 90 + static let yearButtonHeight: CGFloat = 24 + static let messageLabelTopOffset: CGFloat = 12 + static let messageLabelHeight: CGFloat = 26 + static let semesterButtonWidth: CGFloat = 125 + static let semesterButtonHeight: CGFloat = 40 + static let semesterButtonCornerRadius: CGFloat = 4 + static let paddingBetweenMessageAndSemester: CGFloat = 10 + static let paddingBetweenSemesterRows: CGFloat = 10 + } + + private enum SemesterState: Int { + case unselected = 0 + case added = 1 + case existing = 2 + case removed = 3 + } + + // MARK: - Properties + private let onApplyButtonTapped: ([String], [String]) -> Void + + // MARK: - State + private var frameList: [FrameData] = [] private var selectedYear: String = { let currentYear = Calendar.current.component(.year, from: Date()) return "\(currentYear)" }() - private var selectedFrames: Set = [] - - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() - - private let selectYearButton = UIButton().then { - let currentYear = Calendar.current.component(.year, from: Date()) - $0.backgroundColor = UIColor.appColor(.neutral300) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 12) - $0.setTitle("\(currentYear)", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral800), for: .normal) + private var semesterMapping: [(button: UIButton, semester: String)] { + [ + (firstSemesterButton, "\(selectedYear)1"), + (summerSemesterButton, "\(selectedYear)-여름"), + (secondSemesterButton, "\(selectedYear)2"), + (winterSemesterButton, "\(selectedYear)-겨울") + ] } - private let messageLabel = UILabel().then { - $0.text = "학기 편집" - $0.font = UIFont.appFont(.pretendardBold, size: 18) - } - private let firstSemesterButton = UIButton().then { - $0.setTitle("1학기", for: .normal) - } - private let summerSelectButton = UIButton().then { - $0.setTitle("여름학기", for: .normal) - } - private let secondSemesterButton = UIButton().then { - $0.setTitle("2학기", for: .normal) - } - private let winterSemesterButton = UIButton().then { - $0.setTitle("겨울학기", for: .normal) - } - private let cancelButton = UIButton().then { - $0.setTitleColor(UIColor.appColor(.neutral800), for: .normal) - $0.setTitle("취소", for: .normal) - } - private let applyButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitle("적용하기", for: .normal) - } - - init(width: CGFloat, height: CGFloat) { - self.containerWidth = width - self.containerHeight = height - super.init(nibName: nil, bundle: nil) + // MARK: - UI Components + private let containerView = UIView() + private let selectYearButton = UIButton() + private let messageLabel = UILabel() + private let firstSemesterButton = UIButton() + private let summerSemesterButton = UIButton() + private let secondSemesterButton = UIButton() + private let winterSemesterButton = UIButton() + + // MARK: - Initializer + init(onApplyButtonTapped: @escaping ([String], [String]) -> Void) { + self.onApplyButtonTapped = onApplyButtonTapped + + super.init(configuration: .init( + appearance: .primary, + content: .custom(customView: containerView), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "적용하기", + rightButtonAction: {} + ), + layout: .init( + width: 327, + contentTopPadding: 12, + contentHorizontalPadding: 24, + paddingBetweenContentAndButton: 10, + buttonHorizontalPadding: 24 + ) + )) } - - required init?(coder: NSCoder) { + @MainActor required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() configureView() - selectYearButton.addTarget(self, action: #selector(selectYearButtonTapped), for: .touchUpInside) - firstSemesterButton.addTarget(self, action: #selector(semesterButtonTapped(_:)), for: .touchUpInside) - summerSelectButton.addTarget(self, action: #selector(semesterButtonTapped(_:)), for: .touchUpInside) - secondSemesterButton.addTarget(self, action: #selector(semesterButtonTapped(_:)), for: .touchUpInside) - winterSemesterButton.addTarget(self, action: #selector(semesterButtonTapped(_:)), for: .touchUpInside) - - cancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside) - applyButton.addTarget(self, action: #selector(applyButtonTapped), for: .touchUpInside) - - // 초기 상태 업데이트 + setUpAddTargets() updateSemesterButtons() } - - func configre(frameList: [FrameData]) { + + // MARK: - Public + func configure(frameList: [FrameData]) { self.frameList = frameList updateSemesterButtons() } -} -extension ModifySemesterModalViewController { - - private func updateSemesterButtons() { - let semesterMapping: [(UIButton, String)] = [ - (firstSemesterButton, "\(selectedYear)1"), - (summerSelectButton, "\(selectedYear)-여름"), - (secondSemesterButton, "\(selectedYear)2"), - (winterSemesterButton, "\(selectedYear)-겨울") - ] - + + // MARK: - Override + override func rightButtonTapped() { + var addedSemesters: [String] = [] + var removedSemesters: [String] = [] + for (button, semester) in semesterMapping { - if frameList.contains(where: { $0.semester == semester }) { - button.tag = 2 - button.backgroundColor = UIColor.appColor(.success700) - button.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - } else { - button.tag = 0 - button.backgroundColor = UIColor.appColor(.neutral0) - button.setTitleColor(UIColor.appColor(.neutral800), for: .normal) + switch SemesterState(rawValue: button.tag) { + case .added: + addedSemesters.append(semester) + case .removed: + removedSemesters.append(semester) + default: + break } } - } - - @objc private func semesterButtonTapped(_ sender: UIButton) { - switch sender.tag { - case 0: // 초기 상태(흰색) - sender.tag = 1 - sender.backgroundColor = UIColor.appColor(.success700) - sender.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - case 1: // 새로 선택된 학기(초록색) - sender.tag = 0 - sender.backgroundColor = UIColor.appColor(.neutral0) - sender.setTitleColor(UIColor.appColor(.neutral800), for: .normal) - case 2: // 이미 존재하는 학기(초록색) - sender.tag = 3 - sender.backgroundColor = UIColor.appColor(.danger700) - sender.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - case 3: // 삭제 예정(빨간색) - sender.tag = 2 - sender.backgroundColor = UIColor.appColor(.success700) - sender.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - default: - break + + dismiss(animated: true) { [weak self] in + guard let self else { return } + onApplyButtonTapped(addedSemesters, removedSemesters) } } +} - - private func getSemester(for button: UIButton) -> String? { - switch button { - case firstSemesterButton: return "\(selectedYear)1" - case summerSelectButton: return "\(selectedYear)-여름" - case secondSemesterButton: return "\(selectedYear)2" - case winterSemesterButton: return "\(selectedYear)-겨울" - default: return nil +extension ModifySemesterModalViewController { + private func setUpAddTargets() { + selectYearButton.addTarget(self, action: #selector(selectYearButtonTapped), for: .touchUpInside) + [firstSemesterButton, summerSemesterButton, secondSemesterButton, winterSemesterButton].forEach { + $0.addTarget(self, action: #selector(semesterButtonTapped(_:)), for: .touchUpInside) } } - - @objc private func cancelButtonTapped() { - updateSemesterButtons() - dismiss(animated: true, completion: nil) + + private func updateSemesterButtons() { + for (button, semester) in semesterMapping { + let isExisting = frameList.contains { $0.semester == semester } + apply(state: isExisting ? .existing : .unselected, to: button) + } } - - @objc private func applyButtonTapped() { - dismiss(animated: true, completion: nil) - let semesterMapping: [(UIButton, String)] = [ - (firstSemesterButton, "\(selectedYear)1"), - (summerSelectButton, "\(selectedYear)-여름"), - (secondSemesterButton, "\(selectedYear)2"), - (winterSemesterButton, "\(selectedYear)-겨울") - ] - var addedSemesters: [String] = [] - var removedSemesters: [String] = [] + private func apply(state: SemesterState, to button: UIButton) { + button.tag = state.rawValue - for (button, semester) in semesterMapping { - if button.tag == 1 { // 새로 추가될 학기 - addedSemesters.append(semester) - } else if button.tag == 3 { // 삭제될 학기 - removedSemesters.append(semester) - } + switch state { + case .unselected: + button.backgroundColor = .appColor(.neutral0) + button.setTitleColor(.appColor(.neutral800), for: .normal) + case .added, .existing: + button.backgroundColor = .appColor(.success700) + button.setTitleColor(.appColor(.neutral0), for: .normal) + case .removed: + button.backgroundColor = .appColor(.danger700) + button.setTitleColor(.appColor(.neutral0), for: .normal) } - print(addedSemesters) - print(removedSemesters) - // Publish 결과 - applyButtonPublisher.send((addedSemesters, removedSemesters)) + } - updateSemesterButtons() + // MARK: - Objc + @objc private func semesterButtonTapped(_ sender: UIButton) { + switch SemesterState(rawValue: sender.tag) { + case .unselected: // 초기 상태(흰색) + apply(state: .added, to: sender) + case .added: // 새로 선택된 학기(초록색) + apply(state: .unselected, to: sender) + case .existing: // 이미 존재하는 학기(초록색) + apply(state: .removed, to: sender) + case .removed: // 삭제 예정(빨간색) + apply(state: .existing, to: sender) + case .none: + break + } } - @objc private func selectYearButtonTapped() { - // 현재 연도 계산 let currentYear = Calendar.current.component(.year, from: Date()) let years = (2019...currentYear).reversed().map { "\($0)" } // 2019년부터 현재 연도까지 - + let alert = UIAlertController(title: "연도 선택", message: nil, preferredStyle: .actionSheet) - + for year in years { let action = UIAlertAction(title: year, style: .default) { [weak self] _ in guard let self = self else { return } @@ -200,93 +180,90 @@ extension ModifySemesterModalViewController { } alert.addAction(action) } - + let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil) alert.addAction(cancelAction) - + present(alert, animated: true, completion: nil) } +} - - - - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [selectYearButton, messageLabel, firstSemesterButton, summerSelectButton, secondSemesterButton, winterSemesterButton, cancelButton, applyButton].forEach { - containerView.addSubview($0) - } +extension ModifySemesterModalViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(containerWidth) - make.height.equalTo(containerHeight) - } - selectYearButton.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(12) - make.leading.equalTo(containerView.snp.leading).offset(24) - make.width.equalTo(90) - make.height.equalTo(24) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(selectYearButton.snp.bottom) - make.centerX.equalTo(containerView.snp.centerX) + + private func setUpStyles() { + selectYearButton.do { + $0.backgroundColor = .appColor(.neutral300) + $0.titleLabel?.font = .appFont(.pretendardMedium, size: 12) + $0.setTitle(selectedYear, for: .normal) + $0.setTitleColor(.appColor(.neutral800), for: .normal) } - firstSemesterButton.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(14) - make.leading.equalTo(containerView.snp.leading).offset(24) - make.width.equalTo(125) - make.height.equalTo(40) + + messageLabel.do { + $0.text = "학기 편집" + $0.font = .appFont(.pretendardBold, size: 18) } - summerSelectButton.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(14) - make.trailing.equalTo(containerView.snp.trailing).offset(-24) - make.width.height.equalTo(firstSemesterButton) + + firstSemesterButton.setTitle("1학기", for: .normal) + summerSemesterButton.setTitle("여름학기", for: .normal) + secondSemesterButton.setTitle("2학기", for: .normal) + winterSemesterButton.setTitle("겨울학기", for: .normal) + + [firstSemesterButton, summerSemesterButton, secondSemesterButton, winterSemesterButton].forEach { + $0.titleLabel?.font = .appFont(.pretendardMedium, size: 16) + $0.setTitleColor(.appColor(.neutral800), for: .normal) + $0.layer.borderWidth = 1.0 + $0.layer.borderColor = UIColor.appColor(.neutral300).cgColor + $0.layer.cornerRadius = Layout.semesterButtonCornerRadius + $0.layer.masksToBounds = true } - secondSemesterButton.snp.makeConstraints { make in - make.top.equalTo(firstSemesterButton.snp.bottom).offset(10) - make.width.height.leading.equalTo(firstSemesterButton) + } + + private func setUpLayouts() { + [selectYearButton, messageLabel, firstSemesterButton, summerSemesterButton, secondSemesterButton, winterSemesterButton].forEach { + containerView.addSubview($0) } - winterSemesterButton.snp.makeConstraints { make in - make.top.equalTo(secondSemesterButton) - make.width.height.trailing.equalTo(summerSelectButton) + } + + private func setUpConstraints() { + selectYearButton.snp.makeConstraints { + $0.top.leading.equalToSuperview() + $0.width.equalTo(Layout.yearButtonWidth) + $0.height.equalTo(Layout.yearButtonHeight) } - cancelButton.snp.makeConstraints { make in - make.width.equalTo(135.5) - make.height.equalTo(48) - make.trailing.equalTo(containerView.snp.centerX).offset(-4) - make.top.equalTo(winterSemesterButton.snp.bottom).offset(10) + messageLabel.snp.makeConstraints { + $0.top.equalToSuperview().offset(Layout.messageLabelTopOffset) + $0.centerX.equalToSuperview() + $0.height.equalTo(Layout.messageLabelHeight) } - applyButton.snp.makeConstraints { make in - make.width.height.bottom.equalTo(cancelButton) - make.leading.equalTo(containerView.snp.centerX).offset(4) + firstSemesterButton.snp.makeConstraints { + $0.top.equalTo(messageLabel.snp.bottom).offset(Layout.paddingBetweenMessageAndSemester) + $0.leading.equalToSuperview() + $0.width.equalTo(Layout.semesterButtonWidth) + $0.height.equalTo(Layout.semesterButtonHeight) } - } - - private func setUpComponents() { - [firstSemesterButton, secondSemesterButton, summerSelectButton, winterSemesterButton, cancelButton, applyButton].forEach { - $0.layer.borderWidth = 1.0 - $0.layer.borderColor = UIColor.appColor(.neutral300).cgColor - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true + summerSemesterButton.snp.makeConstraints { + $0.top.equalTo(firstSemesterButton) + $0.trailing.equalToSuperview() + $0.width.equalTo(Layout.semesterButtonWidth) + $0.height.equalTo(Layout.semesterButtonHeight) } - [firstSemesterButton, secondSemesterButton, summerSelectButton, winterSemesterButton].forEach { - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 16) - $0.setTitleColor(UIColor.appColor(.neutral800), for: .normal) + secondSemesterButton.snp.makeConstraints { + $0.top.equalTo(firstSemesterButton.snp.bottom).offset(Layout.paddingBetweenSemesterRows) + $0.leading.equalTo(firstSemesterButton) + $0.width.equalTo(Layout.semesterButtonWidth) + $0.height.equalTo(Layout.semesterButtonHeight) + $0.bottom.equalToSuperview() } - [cancelButton, applyButton].forEach { - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) + winterSemesterButton.snp.makeConstraints { + $0.top.equalTo(secondSemesterButton) + $0.trailing.equalTo(summerSemesterButton) + $0.width.equalTo(Layout.semesterButtonWidth) + $0.height.equalTo(Layout.semesterButtonHeight) } } - private func configureView() { - setUpLayOuts() - setUpConstraints() - setUpComponents() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) - } } diff --git a/Koin/Presentation/TimeTable/Timetable/SubViews/DeleteLectureModalViewController.swift b/Koin/Presentation/TimeTable/Timetable/SubViews/DeleteLectureModalViewController.swift index 12305c60..6f8f41f9 100644 --- a/Koin/Presentation/TimeTable/Timetable/SubViews/DeleteLectureModalViewController.swift +++ b/Koin/Presentation/TimeTable/Timetable/SubViews/DeleteLectureModalViewController.swift @@ -5,125 +5,82 @@ // Created by 김나훈 on 12/10/24. // -import Combine import UIKit -final class DeleteLectureModalViewController: UIViewController { +final class DeleteLectureModalViewController: KoinModalViewController { - let deleteButtonPublisher = PassthroughSubject() - private var lectureData: LectureData? = nil + // MARK: - Properties + private let onDeleteButtonTapped: (LectureData) -> Void + private let lectureData: LectureData - private let messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 17) - $0.textColor = UIColor.appColor(.neutral800) - $0.numberOfLines = 0 - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let deleteButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.danger700) - $0.setTitle("삭제하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() - - override func viewDidLoad() { - super.viewDidLoad() - modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve - configureView() - deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) + // MARK: - Initializer + init( + lectureData: LectureData, + onDeleteButtonTapped: @escaping (LectureData) -> Void + ) { + self.lectureData = lectureData + self.onDeleteButtonTapped = onDeleteButtonTapped + + let attributedString = Self.makeAttributedString(lectureName: lectureData.name) + + super.init(configuration: .init( + appearance: .destructive, + content: .attributedSingleTitle(title: attributedString), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "삭제하기", + rightButtonAction: {} + ), + layout: .init( + paddingBetweenContentAndButton: 24 - 9.6 + ) + )) } - - @objc private func closeButtonTapped() { - dismiss(animated: true, completion: nil) + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - @objc private func deleteButtonTapped() { - dismiss(animated: true, completion: nil) - if let lectureData = lectureData { - deleteButtonPublisher.send(lectureData) + // MARK: - Override + override func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + onDeleteButtonTapped(lectureData) } - } } extension DeleteLectureModalViewController { - - func setMessageLabelText(lectureData: LectureData) { - self.lectureData = lectureData - let lectureName = lectureData.name - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - paragraphStyle.alignment = .center - let text = "\(lectureName)\(lectureName.hasFinalConsonant() ? "을":"를") 삭제하시겠어요?\n삭제한 강의는 수업추가에서\n다시 추가할 수 있어요." - let attributedString = NSMutableAttributedString(string: text) + private static func makeAttributedString(lectureName: String) -> NSAttributedString { + let textMedium16 = "\(lectureName)\(lectureName.hasFinalConsonant() ? "을":"를") 삭제하시겠어요?" + let textMedium15 = "삭제한 강의는 수업추가에서\n다시 추가할 수 있어요." + let text = textMedium16 + "\n" + textMedium15 + + let rangeMedium16 = (text as NSString).range(of: textMedium16) + let rangeMedium15 = (text as NSString).range(of: textMedium15) let range1 = (text as NSString).range(of: "삭제") let range2 = (text as NSString).range(of: "수업추가") - attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.danger700), range: range1) - attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.primary500), range: range2) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - messageLabel.attributedText = attributedString - } - - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) - } - [messageLabel, closeButton, deleteButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(301) - make.height.equalTo(194) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(24) - make.centerX.equalTo(containerView.snp.centerX) - } - closeButton.snp.makeConstraints { make in - make.bottom.equalTo(containerView.snp.bottom).offset(-24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - deleteButton.snp.makeConstraints { make in - make.bottom.equalTo(containerView.snp.bottom).offset(-24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) + + let attributedString = NSMutableAttributedString(string: text) + + attributedString.addAttributes([ + .font: UIFont.appFont(.pretendardMedium, size: 16), + .foregroundColor: UIColor.appColor(.neutral800) + ], range: rangeMedium16) + + attributedString.addAttributes([ + .font: UIFont.appFont(.pretendardMedium, size: 15), + .foregroundColor: UIColor.appColor(.neutral800) + ], range: rangeMedium15) + + attributedString.addAttributes([ + .font: UIFont.appFont(.pretendardBold, size: 16), + .foregroundColor: UIColor.appColor(.danger700) + ], range: range1) + + attributedString.addAttributes([ + .foregroundColor: UIColor.appColor(.primary500) + ], range: range2) + + return attributedString } } diff --git a/Koin/Presentation/TimeTable/Timetable/SubViews/SelectDeptModalViewController.swift b/Koin/Presentation/TimeTable/Timetable/SubViews/SelectDeptModalViewController.swift index d41ff7ac..f61dee74 100644 --- a/Koin/Presentation/TimeTable/Timetable/SubViews/SelectDeptModalViewController.swift +++ b/Koin/Presentation/TimeTable/Timetable/SubViews/SelectDeptModalViewController.swift @@ -2,174 +2,212 @@ // SelectDeptModalViewController.swift // koin // -// Created by 김나훈 on 12/6/24. +// Created by 홍기정 on 8/23/26. // -import Combine import UIKit -final class SelectDeptModalViewController: UIViewController { +final class SelectDeptModalViewController: KoinModalViewController { - let selectedDeptPublisher = PassthroughSubject() - private var departmentButtons: [UIButton] = [] - private let departments = [ - "디자인ㆍ건축공학부", - "고용서비스정책학과", - "기계공학부", - "메카트로닉스공학부", - "산업경영학부", - "전기ㆍ전자ㆍ통신공학부", - "컴퓨터공학부", - "에너지신소재화학공학부", - "HRD학과", - "교양학부", - "안전공학과", - "융합학과" - ] - private var selectedDepartment: String? = nil - private var selectedButton: UIButton? = nil + // MARK: - Properties + private let onCompleteButtonTapped: (String?)->Void - private let messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardMedium, size: 18) - $0.textColor = UIColor.appColor(.primary500) - $0.text = "전공선택" - } + // MARK: - Radio Button + private let departmentRadioButtonGroup = RadioButtonGroup() - private let completeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitle("완료", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true + // MARK: - State + var selectedDepartment: String? { + departmentRadioButtonGroup.selectedRadioButton?.accessibilityLabel } - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() + // MARK: - UI Components + private let customView = UIView() + + private let titleLabel = UILabel() + + private let departmentScrollView = UIScrollView() + private let departmentStackView = UIStackView() + private var departmentRadioButtons: [RadioButton] = [] + + private let cancelButton = UIButton() + private let completeButton = UIButton() - private let gridStackView = UIStackView().then { - $0.axis = .vertical - $0.spacing = 4.8 - $0.distribution = .fillEqually + // MARK: - Initializer + init( + departments: [String], + selectedDapartment: String? = nil, + onCompleteButtonTapped: @escaping (String?)->Void + ) { + self.onCompleteButtonTapped = onCompleteButtonTapped + + super.init(configuration: .init( + appearance: .primary, + content: .custom(customView: customView), + button: .none, + layout: .init( + width: 327, + contentTopPadding: 0, + contentHorizontalPadding: 0, + contentBottomPadding: 0 + ) + )) + + setUpRadioButtons( + departments: departments, + selectedDapartment: selectedDapartment + ) + } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } + // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() configureView() - modalPresentationStyle = .overFullScreen - modalTransitionStyle = .crossDissolve - completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) - setupDepartments() + setAddTargets() } +} - @objc private func completeButtonTapped() { - selectedDeptPublisher.send(selectedDepartment) - dismiss(animated: true, completion: nil) - } - - private func setupDepartments() { - departmentButtons.forEach { $0.removeFromSuperview() } - departmentButtons.removeAll() - - let buttonsPerRow = 2 - var currentRow: UIStackView? = nil - gridStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } - - for (index, department) in departments.enumerated() { - let button = createDepartmentButton(title: department) - departmentButtons.append(button) - if index % buttonsPerRow == 0 { - currentRow = UIStackView() - currentRow?.axis = .horizontal - currentRow?.spacing = 4.8 - currentRow?.distribution = .fillEqually - gridStackView.addArrangedSubview(currentRow!) - } +extension SelectDeptModalViewController { + private func setUpRadioButtons( + departments: [String], + selectedDapartment: String? = nil + ) { + for department in departments { + let radioButton = RadioButton(title: department) - currentRow?.addArrangedSubview(button) + departmentRadioButtons.append(radioButton) + departmentRadioButtonGroup.addRadioButton(radioButton) + + if department == selectedDapartment { + departmentRadioButtonGroup.selectRadioButton(radioButton) + } } - - view.layoutIfNeeded() + } +} + +extension SelectDeptModalViewController { + private func setAddTargets() { + cancelButton.addTarget(self, action: #selector(cancelButtonButtonTapped), for: .touchUpInside) + completeButton.addTarget(self, action: #selector(completeButtonTapped), for: .touchUpInside) } - private func createDepartmentButton(title: String) -> UIButton { - let button = UIButton(type: .system) - button.setTitle(title, for: .normal) - button.setTitleColor(UIColor.appColor(.neutral800), for: .normal) - button.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 14) - button.layer.cornerRadius = 4 - button.layer.borderWidth = 1 - button.layer.borderColor = UIColor.appColor(.neutral300).cgColor - button.backgroundColor = .white - button.addTarget(self, action: #selector(departmentButtonTapped(_:)), for: .touchUpInside) - return button + @objc private func cancelButtonButtonTapped() { + dismiss(animated: true) } - @objc private func departmentButtonTapped(_ sender: UIButton) { - guard let department = sender.title(for: .normal) else { return } - - if selectedButton == sender { - sender.backgroundColor = .white - sender.setTitleColor(UIColor.appColor(.neutral800), for: .normal) - selectedButton = nil - selectedDepartment = nil - } else { - departmentButtons.forEach { button in - button.backgroundColor = .white - button.setTitleColor(UIColor.appColor(.neutral800), for: .normal) - } - sender.backgroundColor = UIColor.appColor(.primary500) - sender.setTitleColor(.white, for: .normal) - selectedButton = sender - selectedDepartment = department - } + @objc private func completeButtonTapped() { + onCompleteButtonTapped(selectedDepartment) + dismiss(animated: true) } } extension SelectDeptModalViewController { + private func configureView() { + setUpStyles() + setUpLayouts() + setUpConstraints() + } - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) + private func setUpStyles() { + titleLabel.do { + $0.text = "전공선택" + $0.textColor = .appColor(.primary500) + $0.font = .appFont(.pretendardSemiBold, size: 18) + } + + departmentScrollView.do { + $0.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + $0.verticalScrollIndicatorInsets = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0) + } + + departmentStackView.do { + $0.axis = .vertical + $0.spacing = 8 + $0.alignment = .fill } - [messageLabel, gridStackView, completeButton].forEach { - containerView.addSubview($0) + + cancelButton.do { + $0.setAttributedTitle( + NSAttributedString( + string: "취소", + attributes: [ + .font: UIFont.appFont(.pretendardMedium, size: 14), + .foregroundColor: UIColor.appColor(.neutral500) + ]), + for: .normal + ) + } + completeButton.do { + $0.setAttributedTitle( + NSAttributedString( + string: "완료", + attributes: [ + .font: UIFont.appFont(.pretendardMedium, size: 14), + .foregroundColor: UIColor.appColor(.neutral0) + ]), + for: .normal + ) + $0.backgroundColor = .appColor(.primary500) + $0.layer.cornerRadius = 6 } } - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(327) - make.height.equalTo(323) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(18) - make.leading.equalTo(containerView.snp.leading).offset(12) + private func setUpLayouts() { + departmentRadioButtons.forEach { + departmentStackView.addArrangedSubview($0) } - gridStackView.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(10) - make.leading.equalTo(containerView.snp.leading).offset(12) - make.trailing.equalTo(containerView.snp.trailing).offset(-12) - make.height.equalTo(216) + + [departmentStackView].forEach { + departmentScrollView.addSubview($0) } - completeButton.snp.makeConstraints { make in - make.trailing.equalTo(containerView.snp.trailing).offset(-12) - make.bottom.equalTo(containerView.snp.bottom).offset(-18) - make.width.equalTo(60) - make.height.equalTo(30) + + [titleLabel, + departmentScrollView, + cancelButton, + completeButton].forEach { + customView.addSubview($0) } } - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) + private func setUpConstraints() { + titleLabel.snp.makeConstraints { + $0.height.equalTo(29) + $0.top.equalToSuperview().offset(12) + $0.leading.equalToSuperview().offset(24) + } + + departmentScrollView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(12) + $0.leading.trailing.equalToSuperview().inset(24) + $0.height.equalTo(323) + } + + departmentStackView.snp.makeConstraints { + $0.edges.equalTo(departmentScrollView.contentLayoutGuide) + $0.width.equalTo(departmentScrollView) + } + + departmentRadioButtons.forEach { + $0.snp.makeConstraints { + $0.height.equalTo(24) + } + } + + completeButton.snp.makeConstraints { + $0.width.equalTo(49) + $0.height.equalTo(30) + $0.top.equalTo(departmentScrollView.snp.bottom).offset(12) + $0.trailing.equalToSuperview().offset(-24) + $0.bottom.equalToSuperview().offset(-12) + } + + cancelButton.snp.makeConstraints { + $0.width.equalTo(49) + $0.height.equalTo(30) + $0.trailing.equalTo(completeButton.snp.leading).offset(-8) + $0.bottom.equalTo(completeButton) + } } } diff --git a/Koin/Presentation/TimeTable/Timetable/SubViews/SubstituteTimetableModalViewController.swift b/Koin/Presentation/TimeTable/Timetable/SubViews/SubstituteTimetableModalViewController.swift index 6c45482c..2595ccd6 100644 --- a/Koin/Presentation/TimeTable/Timetable/SubViews/SubstituteTimetableModalViewController.swift +++ b/Koin/Presentation/TimeTable/Timetable/SubViews/SubstituteTimetableModalViewController.swift @@ -5,141 +5,88 @@ // Created by 김나훈 on 12/4/24. // -import Combine import UIKit -final class SubstituteTimetableModalViewController: UIViewController { +final class SubstituteTimetableModalViewController: KoinModalViewController { - let substituteButtonPublisher = PassthroughSubject() + // MARK: - Properties + private let onSubstituteButtonTapped: (Any) -> Void + + // MARK: - State private var lectureData: LectureData? private var customLecture: (String, [Int])? - private let messageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardBold, size: 16) - $0.textColor = UIColor.appColor(.neutral800) - $0.text = "시간표가 중복돼요." - } - - private let subMessageLabel = UILabel().then { - $0.font = UIFont.appFont(.pretendardRegular, size: 14) - $0.textColor = UIColor.appColor(.neutral600) - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineSpacing = 6 - let text = "추가하시려는 시간에 이미 다른 강의가\n있어요. 새로운 강의로 대체하시겠어요?" - let attributedString = NSMutableAttributedString(string: text) - let loginRange = (text as NSString).range(of: "새로운 강의로 대체") - attributedString.addAttribute(.foregroundColor, value: UIColor.appColor(.warning500), range: loginRange) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: text.count)) - $0.attributedText = attributedString - $0.textAlignment = .center - $0.numberOfLines = 2 - } - - private let closeButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.neutral0) - $0.layer.borderColor = UIColor.appColor(.neutral500).cgColor - $0.layer.borderWidth = 1.0 - $0.setTitle("취소", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral600), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let substituteButton = UIButton().then { - $0.backgroundColor = UIColor.appColor(.primary500) - $0.setTitle("대체하기", for: .normal) - $0.setTitleColor(UIColor.appColor(.neutral0), for: .normal) - $0.titleLabel?.font = UIFont.appFont(.pretendardMedium, size: 15) - $0.layer.cornerRadius = 4 - $0.layer.masksToBounds = true - } - - private let containerView: UIView = { - let view = UIView() - view.backgroundColor = .white - view.layer.cornerRadius = 4 - view.layer.masksToBounds = true - return view - }() - - override func viewDidLoad() { - super.viewDidLoad() - configureView() - substituteButton.addTarget(self, action: #selector(substituteButtonTapped), for: .touchUpInside) - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - } - - @objc private func closeButtonTapped() { - dismiss(animated: true, completion: nil) + // MARK: - Initializer + init(onSubstituteButtonTapped: @escaping (Any) -> Void) { + self.onSubstituteButtonTapped = onSubstituteButtonTapped + + let mainTitle = NSAttributedString( + string: "시간표가 중복돼요.", + attributes: [ + .font: UIFont.appFont(.pretendardBold, size: 16), + .foregroundColor: UIColor.appColor(.neutral800) + ] + ) + + let subTitle = { + let fullText = "추가하시려는 시간에 이미 다른 강의가\n있어요. 새로운 강의로 대체하시겠어요?" + let highlightedText = "새로운 강의로 대체" + + let highlightRange = (fullText as NSString).range(of: highlightedText) + + let attributedString = NSMutableAttributedString( + string: fullText, + attributes: [ + .font: UIFont.appFont(.pretendardRegular, size: 14), + .foregroundColor: UIColor.appColor(.neutral600) + ] + ) + + attributedString.addAttribute( + .foregroundColor, + value: UIColor.appColor(.warning500), + range: highlightRange + ) + return attributedString + }() + + super.init(configuration: .init( + appearance: .primary, + content: .attributedTitles( + mainTitle: mainTitle, + subTitle: subTitle + ), + button: .buttons( + leftButtonTitle: "취소", + rightButtonTitle: "대체하기", + rightButtonAction: {} + ) + )) } - - @objc private func substituteButtonTapped() { - dismiss(animated: true, completion: nil) - if let lectureData = lectureData { - substituteButtonPublisher.send(lectureData) - } else if let customLecture = customLecture { - substituteButtonPublisher.send(customLecture) - } + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } + // MARK: - Public func configure(lectureData: LectureData) { - self.lectureData = nil - self.customLecture = nil - self.lectureData = lectureData + self.customLecture = nil } + func configure(customLecture: (String, [Int])) { self.lectureData = nil - self.customLecture = nil - self.customLecture = customLecture } -} - -extension SubstituteTimetableModalViewController { - private func setUpLayOuts() { - [containerView].forEach { - view.addSubview($0) + // MARK: - Override + override func rightButtonTapped() { + dismiss(animated: true) { [weak self] in + guard let self else { return } + if let lectureData { + onSubstituteButtonTapped(lectureData) + } else if let customLecture { + onSubstituteButtonTapped(customLecture) + } } - [messageLabel, subMessageLabel, closeButton, substituteButton].forEach { - containerView.addSubview($0) - } - } - - private func setUpConstraints() { - containerView.snp.makeConstraints { make in - make.centerX.equalTo(view.snp.centerX) - make.centerY.equalTo(view.snp.centerY) - make.width.equalTo(301) - make.height.equalTo(198) - } - messageLabel.snp.makeConstraints { make in - make.top.equalTo(containerView.snp.top).offset(24) - make.centerX.equalTo(containerView.snp.centerX) - } - subMessageLabel.snp.makeConstraints { make in - make.top.equalTo(messageLabel.snp.bottom).offset(16) - make.centerX.equalTo(containerView.snp.centerX) - } - closeButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.trailing.equalTo(containerView.snp.centerX).offset(-2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - substituteButton.snp.makeConstraints { make in - make.top.equalTo(subMessageLabel.snp.bottom).offset(24) - make.leading.equalTo(containerView.snp.centerX).offset(2) - make.width.equalTo(114.5) - make.height.equalTo(48) - } - } - - private func configureView() { - setUpLayOuts() - setUpConstraints() - view.backgroundColor = UIColor.appColor(.neutral800).withAlphaComponent(0.7) } } diff --git a/Koin/Presentation/TimeTable/Timetable/TimetableViewController.swift b/Koin/Presentation/TimeTable/Timetable/TimetableViewController.swift index 802c5819..ac6e975f 100644 --- a/Koin/Presentation/TimeTable/Timetable/TimetableViewController.swift +++ b/Koin/Presentation/TimeTable/Timetable/TimetableViewController.swift @@ -89,17 +89,6 @@ final class TimetableViewController: UIViewController { $0.isHidden = true } - private let substituteTimetableModalViewController = SubstituteTimetableModalViewController().then { - $0.modalPresentationStyle = .overFullScreen - $0.modalTransitionStyle = .crossDissolve - } - - private let selectDeptModalViewController = SelectDeptModalViewController().then { _ in - } - - private let deleteLectureModalViewController: DeleteLectureModalViewController = DeleteLectureModalViewController().then { _ in - } - private let containerView = UIView().then { _ in } @@ -162,6 +151,7 @@ final class TimetableViewController: UIViewController { } self.view.endEditing(true) self.toggleCollectionView(collectionView: self.addClassCollectionView, animate: true) + self.inputSubject.send(.selectedDepartment(nil)) }.store(in: &subscriptions) @@ -179,16 +169,14 @@ final class TimetableViewController: UIViewController { $0.removeFromSuperview() } if lecture.1 && viewModel.checkDuplicatedClassTime(classTime: lecture.0.classTime){ - substituteTimetableModalViewController.configure(lectureData: lecture.0) - self.present(substituteTimetableModalViewController, animated: true) + self.presentSubstituteTimetableModal(lectureData: lecture.0) } else { self.inputSubject.send(.modifyLecture(lecture.0, lecture.1)) } }.store(in: &subscriptions) addClassCollectionView.filterButtonPublisher.sink { [weak self] in - guard let self = self else { return } - self.present(self.selectDeptModalViewController, animated: false) + self?.presentSelectDeptModal() }.store(in: &subscriptions) addClassCollectionView.didTapCellPublisher.sink { [weak self] (selectedLecture, filteredLectures) in @@ -219,8 +207,7 @@ final class TimetableViewController: UIViewController { self.toggleCollectionView(collectionView: self.addDirectCollectionView, animate: true) if viewModel.checkDuplicatedClassTime(classTime: item.1) { - substituteTimetableModalViewController.configure(customLecture: (item.0, item.1)) - self.present(substituteTimetableModalViewController, animated: true) + self.presentSubstituteTimetableModal(customLecture: (item.0, item.1)) } else { self.inputSubject.send(.postCustomLecture(item.0, item.1)) } @@ -237,8 +224,7 @@ final class TimetableViewController: UIViewController { deleteLectureView.deleteButtonPublisher.sink { [weak self] lecture in guard let self = self else { return } self.deleteLectureView.isHidden = true - deleteLectureModalViewController.setMessageLabelText(lectureData: lecture) - self.present(deleteLectureModalViewController, animated: false) + self.presentDeleteLectureModal(lecture: lecture) }.store(in: &subscriptions) timetableCollectionView.heightChangedPublisher.sink { [weak self] in @@ -248,34 +234,59 @@ final class TimetableViewController: UIViewController { } }.store(in: &subscriptions) - selectDeptModalViewController.selectedDeptPublisher.sink { [weak self] dept in - self?.addClassCollectionView.setUpSelectedDept(dept: dept) - }.store(in: &subscriptions) - - deleteLectureModalViewController.deleteButtonPublisher.sink { [weak self] lecture in + } +} + +extension TimetableViewController { + private func presentSubstituteTimetableModal(lectureData: LectureData) { + let viewController = makeSubstituteTimetableModal() + viewController.configure(lectureData: lectureData) + present(viewController, animated: true) + } + + private func presentSubstituteTimetableModal(customLecture: (String, [Int])) { + let viewController = makeSubstituteTimetableModal() + viewController.configure(customLecture: customLecture) + present(viewController, animated: true) + } + + private func makeSubstituteTimetableModal() -> SubstituteTimetableModalViewController { + SubstituteTimetableModalViewController { [weak self] response in + self?.handleSubstituteResponse(response) + } + } + + private func handleSubstituteResponse(_ response: Any) { + let completion: (Subscribers.Completion) -> Void = { [weak self] _ in + self?.viewModel.selectedFrameId = self?.viewModel.selectedFrameId + } + if let lectureData = response as? LectureData { + viewModel.performLectureModification(lectureData: lectureData) + .sink(receiveCompletion: completion, receiveValue: { _ in }) + .store(in: &subscriptions) + } else if let customLecture = response as? (String, [Int]) { + viewModel.performCustomLectureModification(lectureName: customLecture.0, lectureTime: customLecture.1) + .sink(receiveCompletion: completion, receiveValue: { _ in }) + .store(in: &subscriptions) + } + } + + private func presentSelectDeptModal() { + let modalViewController = SelectDeptModalViewController( + departments: viewModel.departments, + selectedDapartment: viewModel.selectedDepartment + ) { [weak self] department in + self?.addClassCollectionView.setUpSelectedDept(dept: department) + self?.inputSubject.send(.selectedDepartment(department)) + } + present(modalViewController, animated: true) + } + + private func presentDeleteLectureModal(lecture: LectureData) { + let modalViewController = DeleteLectureModalViewController(lectureData: lecture) { [weak self] lecture in self?.inputSubject.send(._deleteLecture(lecture)) - }.store(in: &subscriptions) - - substituteTimetableModalViewController.substituteButtonPublisher.sink { [weak self] response in - guard let self = self else { return } - - if let lectureData = response as? LectureData { - self.viewModel.performLectureModification(lectureData: lectureData).sink( - receiveCompletion: { _ in - self.viewModel.selectedFrameId = self.viewModel.selectedFrameId - }, - receiveValue: { _ in } - ).store(in: &self.subscriptions) - - } else if let customLecture = response as? (String, [Int]) { - self.viewModel.performCustomLectureModification(lectureName: customLecture.0, lectureTime: customLecture.1).sink( - receiveCompletion: { _ in - self.viewModel.selectedFrameId = self.viewModel.selectedFrameId - }, - receiveValue: { _ in } - ).store(in: &self.subscriptions) - } - }.store(in: &subscriptions) + } + present(modalViewController, animated: true) } } @@ -513,7 +524,8 @@ extension TimetableViewController { } } @objc private func modifyTimetableButtonTapped() { - + addClassCollectionView.setUpSelectedDept(dept: nil) + inputSubject.send(.selectedDepartment(nil)) if addClassCollectionView.isHidden && addDirectCollectionView.isHidden { toggleCollectionView(collectionView: addClassCollectionView, animate: true) diff --git a/Koin/Presentation/TimeTable/Timetable/TimetableViewModel.swift b/Koin/Presentation/TimeTable/Timetable/TimetableViewModel.swift index cc1a6c97..134191de 100644 --- a/Koin/Presentation/TimeTable/Timetable/TimetableViewModel.swift +++ b/Koin/Presentation/TimeTable/Timetable/TimetableViewModel.swift @@ -22,6 +22,8 @@ final class TimetableViewModel: ViewModelProtocol { case modifyLecture(LectureData, Bool) case _deleteLecture(LectureData) case postCustomLecture(String, [Int]) + + case selectedDepartment(String?) } // MARK: - Output @@ -104,6 +106,10 @@ final class TimetableViewModel: ViewModelProtocol { } } + // 전체 전공 목록 + private(set) var departments: [String] = [] + private(set) var selectedDepartment: String? + // MARK: - Initialization @@ -119,6 +125,8 @@ final class TimetableViewModel: ViewModelProtocol { self?.deleteLectureById(lecture: lecture) case let .postCustomLecture(lectureName, lectureTime): self?.postCustomLecture(lectureName: lectureName, classTime: lectureTime) + case let .selectedDepartment(selectedDepartment): + self?.selectedDepartment = selectedDepartment } }.store(in: &subscriptions) return outputSubject.eraseToAnyPublisher() @@ -387,6 +395,7 @@ extension TimetableViewModel { receiveCompletion: { _ in }, receiveValue: { [weak self] response in self?.outputSubject.send(.updateLectureList(response)) + self?.updateDeparments(response) } ).store(in: &subscriptions) } @@ -462,3 +471,15 @@ extension TimetableViewModel { ).store(in: &subscriptions) } } + +extension TimetableViewModel { + private func updateDeparments(_ response: [SemesterLecture]) { + self.departments = response + .map(\.department) + .reduce(into: Set(), { departments, department in + departments.insert(department) + }) + .sorted() + self.selectedDepartment = nil + } +} diff --git a/Koin/Resources/Assets.xcassets/Home/categoryChat.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Home/categoryChat.imageset/Contents.json new file mode 100644 index 00000000..67066b1e --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Home/categoryChat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "categoryChat.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Home/categoryChat.imageset/categoryChat.svg b/Koin/Resources/Assets.xcassets/Home/categoryChat.imageset/categoryChat.svg new file mode 100644 index 00000000..45e5654b --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Home/categoryChat.imageset/categoryChat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json new file mode 100644 index 00000000..b26f0aac --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "categoryRecruit.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg new file mode 100644 index 00000000..2e945dbb --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Home/categoryRecruit.imageset/categoryRecruit.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Koin/Resources/Assets.xcassets/Notice/noticeAISummary.imageset/Contents.json b/Koin/Resources/Assets.xcassets/Notice/noticeAISummary.imageset/Contents.json new file mode 100644 index 00000000..0fbb5d27 --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Notice/noticeAISummary.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "noticeAISummary.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Koin/Resources/Assets.xcassets/Notice/noticeAISummary.imageset/noticeAISummary.svg b/Koin/Resources/Assets.xcassets/Notice/noticeAISummary.imageset/noticeAISummary.svg new file mode 100644 index 00000000..a1b9b00f --- /dev/null +++ b/Koin/Resources/Assets.xcassets/Notice/noticeAISummary.imageset/noticeAISummary.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index 9512a76f..7ffa9230 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -70,7 +70,7 @@ extension NotificationService { throw NotificationHistoryError.parsingError } - let notificationRecord = NotificationRecord( + let notificationRecord = NotificationHistoryRecord( body: body, title: title, category: appPath, diff --git a/koin.xcodeproj/project.pbxproj b/koin.xcodeproj/project.pbxproj index 53fc9e6d..d9971c4a 100644 --- a/koin.xcodeproj/project.pbxproj +++ b/koin.xcodeproj/project.pbxproj @@ -58,6 +58,13 @@ 7C17EEB02EBF708B008BCA89 /* ShopSearchDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C17EEAF2EBF708B008BCA89 /* ShopSearchDto.swift */; }; 7C17EEB22EBF7175008BCA89 /* FetchSearchShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C17EEB12EBF7175008BCA89 /* FetchSearchShopUseCase.swift */; }; 7C281CFD2EB3ED4B00BD6B4E /* FetchOrderShopDetailFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C281CFC2EB3ED4B00BD6B4E /* FetchOrderShopDetailFromShopUseCase.swift */; }; + 7C333E7C302E285B009D2B89 /* KoinModalAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333E7B302E285B009D2B89 /* KoinModalAnimator.swift */; }; + 7C333E7E302E28AA009D2B89 /* KoinModalPresentationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333E7D302E28AA009D2B89 /* KoinModalPresentationController.swift */; }; + 7C333E9330301B3D009D2B89 /* KoinModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333E9230301B3D009D2B89 /* KoinModalViewController.swift */; }; + 7C333E9530301CB7009D2B89 /* KoinModalConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333E9430301CB7009D2B89 /* KoinModalConfiguration.swift */; }; + 7C333E9C303026B6009D2B89 /* KoinModalStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333E9B303026B6009D2B89 /* KoinModalStyle.swift */; }; + 7C333EA330303678009D2B89 /* ModalContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333EA230303678009D2B89 /* ModalContentView.swift */; }; + 7C333EA53030429B009D2B89 /* ModalButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C333EA43030429B009D2B89 /* ModalButtonView.swift */; }; 7C372ED92F1B7F3900149729 /* FetchLostItemListRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372ED82F1B7F3900149729 /* FetchLostItemListRequest.swift */; }; 7C372EDD2F1B8DC900149729 /* LostItemListFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372EDC2F1B8DC900149729 /* LostItemListFilterButton.swift */; }; 7C372EE62F1C0CC400149729 /* LostItemListData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372EE52F1C0CC400149729 /* LostItemListData.swift */; }; @@ -72,7 +79,6 @@ 7C372F292F1D6A7F00149729 /* LostItemDataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372F282F1D6A7F00149729 /* LostItemDataViewModel.swift */; }; 7C372F322F1DB4C300149729 /* LostItemImagesCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372F302F1DB4C300149729 /* LostItemImagesCollectionViewCell.swift */; }; 7C372F332F1DB4C300149729 /* LostItemImagesCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372F2F2F1DB4C300149729 /* LostItemImagesCollectionView.swift */; }; - 7C372F392F1DCFF800149729 /* ModalViewControllerB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C372F382F1DCFF800149729 /* ModalViewControllerB.swift */; }; 7C3ECC7A2F41505000EE8F13 /* ErrorResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C3ECC792F41505000EE8F13 /* ErrorResponse.swift */; }; 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C457AE62FCDE7240011D338 /* SwiftUIViewModelProtocol.swift */; }; 7C457B2B2FCE1BA80011D338 /* DiningView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C457B092FCE1BA80011D338 /* DiningView.swift */; }; @@ -217,7 +223,9 @@ 7C7BD71D302B2B0A003C5A15 /* AppImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BD71C302B2B0A003C5A15 /* AppImage.swift */; }; 7C7BD71F302B2B37003C5A15 /* PostLostItemUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BD71E302B2B37003C5A15 /* PostLostItemUseCase.swift */; }; 7C7BD721302B2C25003C5A15 /* ReportLostItemUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BD720302B2C25003C5A15 /* ReportLostItemUseCase.swift */; }; - 7C7CCE4A2F834D4B00E3A54B /* CallVanModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7CCE492F834D4B00E3A54B /* CallVanModalViewController.swift */; }; + 7C7BD723302B3D8F003C5A15 /* NoticeAISummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BD722302B3D8F003C5A15 /* NoticeAISummary.swift */; }; + 7C7BD725302B3E25003C5A15 /* NoticeAISummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BD724302B3E25003C5A15 /* NoticeAISummaryView.swift */; }; + 7C7BD727302D8002003C5A15 /* NoticeAISummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7BD726302D8002003C5A15 /* NoticeAISummaryDto.swift */; }; 7C7CCE702F866E9400E3A54B /* AppPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */; }; 7C7CCE722F86863200E3A54B /* FetchCallVanRestrictionUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7CCE712F86863200E3A54B /* FetchCallVanRestrictionUseCase.swift */; }; 7C7CCE742F86866B00E3A54B /* CallVanRestriction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7CCE732F86866B00E3A54B /* CallVanRestriction.swift */; }; @@ -229,11 +237,15 @@ 7C7F43282F65F31500CC5860 /* CallVanReportRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F43272F65F31500CC5860 /* CallVanReportRequest.swift */; }; 7C7F432A2F65F42300CC5860 /* CallVanRecruitmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F43292F65F42300CC5860 /* CallVanRecruitmentState.swift */; }; 7C7F56B42FFB6A6400847151 /* NotificationPopUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B32FFB6A6400847151 /* NotificationPopUpViewController.swift */; }; - 7C7F56B72FFB9FE000847151 /* NotificationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */; }; - 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */; }; + 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationHistoryRecord.swift */; }; + 7C7F56B82FFBA06200847151 /* NotificationHistoryRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56B62FFB9FE000847151 /* NotificationHistoryRecord.swift */; }; 7C7F56B92FFBA0A700847151 /* AppPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */; }; 7C7F56BB2FFBA18100847151 /* NotificationHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */; }; 7C7F56BC2FFBA18100847151 /* NotificationHistoryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */; }; + 7C8261973040344700C20F64 /* FilterGroupCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8261963040344700C20F64 /* FilterGroupCollectionView.swift */; }; + 7C82619930403C5E00C20F64 /* FilterGroupCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C82619830403C5E00C20F64 /* FilterGroupCollectionViewCell.swift */; }; + 7C82619C30407FF300C20F64 /* FilterGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C82619B30407FF300C20F64 /* FilterGroupView.swift */; }; + 7C8261A03040C7E700C20F64 /* CallVanListRequest+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C82619F3040C7E700C20F64 /* CallVanListRequest+.swift */; }; 7C82FF782E9D18C7006335A7 /* OrderShopDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C82FF772E9D18C7006335A7 /* OrderShopDetail.swift */; }; 7C85D2752EBDE374005E63FF /* FetchOrderShopMenusAndGroupsFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C85D2742EBDE374005E63FF /* FetchOrderShopMenusAndGroupsFromShopUseCase.swift */; }; 7C86750E30061AA0003CB942 /* CheckHasUnreadNotificationHistoryUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C86750D30061AA0003CB942 /* CheckHasUnreadNotificationHistoryUseCase.swift */; }; @@ -246,7 +258,7 @@ 7C8A000F2FD200000011D338 /* DiningIndicatorChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A00102FD200000011D338 /* DiningIndicatorChip.swift */; }; 7C8A941E2FD0A94F00DEA6F5 /* NotificationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */; }; 7C8A941F2FD0A94F00DEA6F5 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */; }; - 7C8A94222FD0A96400DEA6F5 /* NotificationEmptyBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyBackgroundView.swift */; }; + 7C8A94222FD0A96400DEA6F5 /* NotificationEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyView.swift */; }; 7C8A94272FD0A97400DEA6F5 /* NotificationFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94242FD0A97400DEA6F5 /* NotificationFooterView.swift */; }; 7C8A94282FD0A97400DEA6F5 /* NotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94262FD0A97400DEA6F5 /* NotificationTableViewCell.swift */; }; 7C8A94292FD0A97400DEA6F5 /* NotificationTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8A94252FD0A97400DEA6F5 /* NotificationTableView.swift */; }; @@ -269,12 +281,15 @@ 7C8ADD3E2F20C2C400F85BDE /* LostItemRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8ADD3D2F20C2C400F85BDE /* LostItemRepository.swift */; }; 7C8ADD402F20C30700F85BDE /* DefaultLostItemRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8ADD3F2F20C30700F85BDE /* DefaultLostItemRepository.swift */; }; 7C8ADD452F20C3FB00F85BDE /* FetchLostItemListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8ADD442F20C3FB00F85BDE /* FetchLostItemListUseCase.swift */; }; + 7C8B37A93040287D00A7EB5A /* FilterBottomSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8B37A83040287D00A7EB5A /* FilterBottomSheetView.swift */; }; + 7C8B37AD3040289500A7EB5A /* FilterItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8B37AC3040289500A7EB5A /* FilterItemModel.swift */; }; + 7C8B37B0304028B400A7EB5A /* FilterGroupModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8B37AF304028B400A7EB5A /* FilterGroupModel.swift */; }; 7C8BFCC32FCFF47600963679 /* ActionBindableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCC22FCFF47600963679 /* ActionBindableView.swift */; }; 7C8BFCD32FD0272900963679 /* View+border.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCD22FD0272900963679 /* View+border.swift */; }; 7C8BFCD62FD032A000963679 /* View+linespacing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCD52FD032A000963679 /* View+linespacing.swift */; }; 7C8BFCE12FD04B8000963679 /* HomeDiningItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDD2FD04B8000963679 /* HomeDiningItem.swift */; }; 7C8BFCE32FD04B8000963679 /* HomeHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDE2FD04B8000963679 /* HomeHeader.swift */; }; - 7C8BFCE42FD04B8000963679 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCE02FD04B8000963679 /* NotificationItem.swift */; }; + 7C8BFCE42FD04B8000963679 /* NotificationHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCE02FD04B8000963679 /* NotificationHistoryItem.swift */; }; 7C8BFCE52FD04B8000963679 /* CategoryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCDC2FD04B8000963679 /* CategoryModels.swift */; }; 7C8BFCEB2FD04BA000963679 /* FetchHomeDiningListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCE62FD04BA000963679 /* FetchHomeDiningListUseCase.swift */; }; 7C8BFCEC2FD04BA000963679 /* FetchHeaderUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8BFCE72FD04BA000963679 /* FetchHeaderUseCase.swift */; }; @@ -369,7 +384,6 @@ 7CC42B1E2F56F3B200940CE1 /* CallVanButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B1D2F56F3B200940CE1 /* CallVanButton.swift */; }; 7CC42B202F56F3C400940CE1 /* CallVanState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B1F2F56F3C400940CE1 /* CallVanState.swift */; }; 7CC42B222F56F9C100940CE1 /* CallVanList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B212F56F9C100940CE1 /* CallVanList.swift */; }; - 7CC42B282F575E8100940CE1 /* CallVanListFilterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B272F575E8100940CE1 /* CallVanListFilterViewController.swift */; }; 7CC42B2A2F575F4D00940CE1 /* CallVanFilterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B292F575F4D00940CE1 /* CallVanFilterButton.swift */; }; 7CC42B2F2F58105B00940CE1 /* CallVanBottomSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B2E2F58105B00940CE1 /* CallVanBottomSheetViewController.swift */; }; 7CC42B332F58247D00940CE1 /* CallVanNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B322F58247D00940CE1 /* CallVanNotification.swift */; }; @@ -397,12 +411,12 @@ 7CC42B892F5D85F700940CE1 /* CallVanChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B882F5D85F700940CE1 /* CallVanChat.swift */; }; 7CC42B8F2F5DCE7400940CE1 /* CallVanChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B8E2F5DCE7400940CE1 /* CallVanChatViewController.swift */; }; 7CC42B912F5DCE7D00940CE1 /* CallVanChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B902F5DCE7D00940CE1 /* CallVanChatViewModel.swift */; }; - 7CC42B942F5DCEA600940CE1 /* CallVanChatTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B932F5DCEA600940CE1 /* CallVanChatTableView.swift */; }; - 7CC42B962F5DCEB300940CE1 /* CallVanChatDateHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B952F5DCEB300940CE1 /* CallVanChatDateHeaderView.swift */; }; - 7CC42B982F5E812F00940CE1 /* CallVanChatLeftCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B972F5E812F00940CE1 /* CallVanChatLeftCell.swift */; }; - 7CC42B9C2F5E834E00940CE1 /* CallVanChatRightCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B9B2F5E834E00940CE1 /* CallVanChatRightCell.swift */; }; - 7CCB1E4B2F29D87C00472669 /* PostChatDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4A2F29D87C00472669 /* PostChatDetailUseCase.swift */; }; - 7CCB1E4E2F29DB3200472669 /* PostChatDetailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4D2F29DB3200472669 /* PostChatDetailRequest.swift */; }; + 7CC42B942F5DCEA600940CE1 /* ChatTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B932F5DCEA600940CE1 /* ChatTableView.swift */; }; + 7CC42B962F5DCEB300940CE1 /* ChatDateHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B952F5DCEB300940CE1 /* ChatDateHeaderView.swift */; }; + 7CC42B982F5E812F00940CE1 /* ChatLeftCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B972F5E812F00940CE1 /* ChatLeftCell.swift */; }; + 7CC42B9C2F5E834E00940CE1 /* ChatRightCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CC42B9B2F5E834E00940CE1 /* ChatRightCell.swift */; }; + 7CCB1E4B2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4A2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift */; }; + 7CCB1E4E2F29DB3200472669 /* LostItemPostChatDetailRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */; }; 7CD411EA2FEB92280079467B /* HomeTabBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD411E92FEB92280079467B /* HomeTabBarItem.swift */; }; 7CEB3B4B2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */; }; 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FD02EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift */; }; @@ -412,10 +426,10 @@ 7CED90C32EF2D1F900457128 /* ShopSummaryPriceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FCC2EF2D1F900457128 /* ShopSummaryPriceTableViewCell.swift */; }; 7CED90C62EF2D1F900457128 /* TimetableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED909A2EF2D1F900457128 /* TimetableCollectionViewCell.swift */; }; 7CED90CA2EF2D1F900457128 /* LandOptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90B02EF2D1F900457128 /* LandOptionCollectionViewCell.swift */; }; - 7CED90CB2EF2D1F900457128 /* ChatListTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EF92EF2D1F900457128 /* ChatListTableViewModel.swift */; }; + 7CED90CB2EF2D1F900457128 /* LostItemChatListTableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EF92EF2D1F900457128 /* LostItemChatListTableViewModel.swift */; }; 7CED90CE2EF2D1F900457128 /* ForceModifyUserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8ED42EF2D1F900457128 /* ForceModifyUserViewController.swift */; }; 7CED90CF2EF2D1F900457128 /* ChangeMyProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F0C2EF2D1F900457128 /* ChangeMyProfileViewModel.swift */; }; - 7CED90D02EF2D1F900457128 /* ChatHistoryTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFC2EF2D1F900457128 /* ChatHistoryTableView.swift */; }; + 7CED90D02EF2D1F900457128 /* LostItemChatHistoryTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFC2EF2D1F900457128 /* LostItemChatHistoryTableView.swift */; }; 7CED90D12EF2D1F900457128 /* NoticeSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F572EF2D1F900457128 /* NoticeSearchViewController.swift */; }; 7CED90D22EF2D1F900457128 /* TagCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90152EF2D1F900457128 /* TagCollectionViewCell.swift */; }; 7CED90D32EF2D1F900457128 /* LandCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90A92EF2D1F900457128 /* LandCollectionView.swift */; }; @@ -423,7 +437,7 @@ 7CED90D72EF2D1F900457128 /* RecommendedKeywordCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F5A2EF2D1F900457128 /* RecommendedKeywordCollectionView.swift */; }; 7CED90D92EF2D1F900457128 /* DiningViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90742EF2D1F900457128 /* DiningViewController.swift */; }; 7CED90DA2EF2D1F900457128 /* RecentSearchTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F532EF2D1F900457128 /* RecentSearchTableView.swift */; }; - 7CED90DB2EF2D1F900457128 /* ChatImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFE2EF2D1F900457128 /* ChatImageTableViewCell.swift */; }; + 7CED90DB2EF2D1F900457128 /* LostItemChatImageTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFE2EF2D1F900457128 /* LostItemChatImageTableViewCell.swift */; }; 7CED90DC2EF2D1F900457128 /* ShopSummaryDeliveryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FC42EF2D1F900457128 /* ShopSummaryDeliveryButton.swift */; }; 7CED90DD2EF2D1F900457128 /* AddDirectCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90912EF2D1F900457128 /* AddDirectCollectionViewCell.swift */; }; 7CED90DF2EF2D1F900457128 /* LandDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90B72EF2D1F900457128 /* LandDetailViewModel.swift */; }; @@ -431,7 +445,6 @@ 7CED90E22EF2D1F900457128 /* PointLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90892EF2D1F900457128 /* PointLabel.swift */; }; 7CED90E32EF2D1F900457128 /* FindPhoneIdViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F2F2EF2D1F900457128 /* FindPhoneIdViewController.swift */; }; 7CED90E42EF2D1F900457128 /* SubstituteTimetableModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED908D2EF2D1F900457128 /* SubstituteTimetableModalViewController.swift */; }; - 7CED90E62EF2D1F900457128 /* DeleteReviewModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED900D2EF2D1F900457128 /* DeleteReviewModalViewController.swift */; }; 7CED90E72EF2D1F900457128 /* ForceUpdateViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EE02EF2D1F900457128 /* ForceUpdateViewController.swift */; }; 7CED90E92EF2D1F900457128 /* DiningCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED906C2EF2D1F900457128 /* DiningCollectionView.swift */; }; 7CED90EB2EF2D1F900457128 /* LandViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90AC2EF2D1F900457128 /* LandViewController.swift */; }; @@ -439,9 +452,9 @@ 7CED90F02EF2D1F900457128 /* RevokeModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F0B2EF2D1F900457128 /* RevokeModalViewController.swift */; }; 7CED90F12EF2D1F900457128 /* ForceUpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EDF2EF2D1F900457128 /* ForceUpdateViewModel.swift */; }; 7CED90F22EF2D1F900457128 /* ShopSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FBF2EF2D1F900457128 /* ShopSearchViewModel.swift */; }; - 7CED90F42EF2D1F900457128 /* ChatListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFA2EF2D1F900457128 /* ChatListTableViewController.swift */; }; + 7CED90F42EF2D1F900457128 /* LostItemChatListTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFA2EF2D1F900457128 /* LostItemChatListTableViewController.swift */; }; 7CED90F52EF2D1F900457128 /* DiningViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90732EF2D1F900457128 /* DiningViewModel.swift */; }; - 7CED90F62EF2D1F900457128 /* ChatDateHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFF2EF2D1F900457128 /* ChatDateHeaderView.swift */; }; + 7CED90F62EF2D1F900457128 /* LostItemChatDateHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFF2EF2D1F900457128 /* LostItemChatDateHeaderView.swift */; }; 7CED90F72EF2D1F900457128 /* ReviewImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90032EF2D1F900457128 /* ReviewImageCollectionViewCell.swift */; }; 7CED90FA2EF2D1F900457128 /* LandDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90B82EF2D1F900457128 /* LandDetailViewController.swift */; }; 7CED90FB2EF2D1F900457128 /* TabBarCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F3E2EF2D1F900457128 /* TabBarCollectionViewCell.swift */; }; @@ -454,7 +467,7 @@ 7CED91042EF2D1F900457128 /* TimetableCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90822EF2D1F900457128 /* TimetableCell.swift */; }; 7CED91052EF2D1F900457128 /* SelectTypeFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F2B2EF2D1F900457128 /* SelectTypeFormViewController.swift */; }; 7CED91072EF2D1F900457128 /* AddDirectHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90922EF2D1F900457128 /* AddDirectHeaderView.swift */; }; - 7CED91082EF2D1F900457128 /* BlockCheckModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F012EF2D1F900457128 /* BlockCheckModalViewController.swift */; }; + 7CED91082EF2D1F900457128 /* LostItemBlockCheckModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F012EF2D1F900457128 /* LostItemBlockCheckModalViewController.swift */; }; 7CED91092EF2D1F900457128 /* FacilityInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90A42EF2D1F900457128 /* FacilityInfoViewController.swift */; }; 7CED910A2EF2D1F900457128 /* LeftAlignedFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90132EF2D1F900457128 /* LeftAlignedFlowLayout.swift */; }; 7CED910B2EF2D1F900457128 /* AddClassCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90952EF2D1F900457128 /* AddClassCollectionView.swift */; }; @@ -463,7 +476,7 @@ 7CED910E2EF2D1F900457128 /* ShopSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FBE2EF2D1F900457128 /* ShopSearchViewController.swift */; }; 7CED910F2EF2D1F900457128 /* ShopCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */; }; 7CED91122EF2D1F900457128 /* LectureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90882EF2D1F900457128 /* LectureView.swift */; }; - 7CED91142EF2D1F900457128 /* ChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F032EF2D1F900457128 /* ChatViewController.swift */; }; + 7CED91142EF2D1F900457128 /* LostItemChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F032EF2D1F900457128 /* LostItemChatViewController.swift */; }; 7CED91152EF2D1F900457128 /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F232EF2D1F900457128 /* LoginViewModel.swift */; }; 7CED91162EF2D1F900457128 /* ShopSortOptionSheetViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FB52EF2D1F900457128 /* ShopSortOptionSheetViewController.swift */; }; 7CED91172EF2D1F900457128 /* TimetableText+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90A02EF2D1F900457128 /* TimetableText+.swift */; }; @@ -528,7 +541,6 @@ 7CED917A2EF2D1F900457128 /* ShopSummaryTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FCE2EF2D1F900457128 /* ShopSummaryTableView.swift */; }; 7CED917D2EF2D1F900457128 /* ReviewImageUploadCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90172EF2D1F900457128 /* ReviewImageUploadCollectionView.swift */; }; 7CED917F2EF2D1F900457128 /* NoticeListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F4D2EF2D1F900457128 /* NoticeListViewController.swift */; }; - 7CED91802EF2D1F900457128 /* ReviewLoginModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED900C2EF2D1F900457128 /* ReviewLoginModalViewController.swift */; }; 7CED91822EF2D1F900457128 /* DeliveryTipsCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FDC2EF2D1F900457128 /* DeliveryTipsCollectionView.swift */; }; 7CED91832EF2D1F900457128 /* NoticeDataViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F702EF2D1F900457128 /* NoticeDataViewModel.swift */; }; 7CED91852EF2D1F900457128 /* DiningOperatingTimeCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90762EF2D1F900457128 /* DiningOperatingTimeCollectionView.swift */; }; @@ -539,7 +551,6 @@ 7CED918A2EF2D1F900457128 /* NoticeDataViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F6F2EF2D1F900457128 /* NoticeDataViewController.swift */; }; 7CED918B2EF2D1F900457128 /* LostArticleImageCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EE82EF2D1F900457128 /* LostArticleImageCollectionView.swift */; }; 7CED918D2EF2D1F900457128 /* ScoreChartCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90002EF2D1F900457128 /* ScoreChartCollectionViewCell.swift */; }; - 7CED918F2EF2D1F900457128 /* BackButtonPopUpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED901D2EF2D1F900457128 /* BackButtonPopUpViewController.swift */; }; 7CED91902EF2D1F900457128 /* StateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F342EF2D1F900457128 /* StateView.swift */; }; 7CED91912EF2D1F900457128 /* TabBarCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F3D2EF2D1F900457128 /* TabBarCollectionView.swift */; }; 7CED91922EF2D1F900457128 /* ShopSummaryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FDA2EF2D1F900457128 /* ShopSummaryViewController.swift */; }; @@ -548,7 +559,6 @@ 7CED91962EF2D1F900457128 /* ScoreChartCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FFF2EF2D1F900457128 /* ScoreChartCollectionView.swift */; }; 7CED91982EF2D1F900457128 /* PolicyListTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F1B2EF2D1F900457128 /* PolicyListTableView.swift */; }; 7CED91992EF2D1F900457128 /* ShopSummaryPhoneButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FC62EF2D1F900457128 /* ShopSummaryPhoneButton.swift */; }; - 7CED919B2EF2D1F900457128 /* UpdateModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EE12EF2D1F900457128 /* UpdateModalViewController.swift */; }; 7CED919E2EF2D1F900457128 /* ChangePasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F122EF2D1F900457128 /* ChangePasswordViewController.swift */; }; 7CED919F2EF2D1F900457128 /* RecentSearchTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F542EF2D1F900457128 /* RecentSearchTableViewCell.swift */; }; 7CED91A02EF2D1F900457128 /* StateButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F332EF2D1F900457128 /* StateButton.swift */; }; @@ -568,23 +578,21 @@ 7CED91BA2EF2D1F900457128 /* CertificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F0E2EF2D1F900457128 /* CertificationView.swift */; }; 7CED91BB2EF2D1F900457128 /* EnterFormViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F282EF2D1F900457128 /* EnterFormViewController.swift */; }; 7CED91BD2EF2D1F900457128 /* RecommendedSearchCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F512EF2D1F900457128 /* RecommendedSearchCollectionViewCell.swift */; }; - 7CED91C02EF2D1F900457128 /* ChatTextTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFD2EF2D1F900457128 /* ChatTextTableViewCell.swift */; }; + 7CED91C02EF2D1F900457128 /* LostItemChatTextTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8EFD2EF2D1F900457128 /* LostItemChatTextTableViewCell.swift */; }; 7CED91C12EF2D1F900457128 /* ShopViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FB72EF2D1F900457128 /* ShopViewModel.swift */; }; 7CED91C22EF2D1F900457128 /* ReviewImageCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90022EF2D1F900457128 /* ReviewImageCollectionView.swift */; }; 7CED91C62EF2D1F900457128 /* HotArticlesNoticeTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F652EF2D1F900457128 /* HotArticlesNoticeTableViewCell.swift */; }; 7CED91C92EF2D1F900457128 /* CategoryCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FAB2EF2D1F900457128 /* CategoryCollectionView.swift */; }; 7CED91CB2EF2D1F900457128 /* ShopSummaryImagesCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FC22EF2D1F900457128 /* ShopSummaryImagesCollectionViewCell.swift */; }; - 7CED91CD2EF2D1F900457128 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F022EF2D1F900457128 /* ChatViewModel.swift */; }; + 7CED91CD2EF2D1F900457128 /* LostItemChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F022EF2D1F900457128 /* LostItemChatViewModel.swift */; }; 7CED91CE2EF2D1F900457128 /* NotiViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F142EF2D1F900457128 /* NotiViewModel.swift */; }; 7CED91CF2EF2D1F900457128 /* ManageNoticeKeywordViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F622EF2D1F900457128 /* ManageNoticeKeywordViewModel.swift */; }; 7CED91D22EF2D1F900457128 /* LandImageCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90B32EF2D1F900457128 /* LandImageCollectionViewCell.swift */; }; - 7CED91D52EF2D1F900457128 /* ImageDropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90082EF2D1F900457128 /* ImageDropDownCell.swift */; }; 7CED91D72EF2D1F900457128 /* ShopDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FE82EF2D1F900457128 /* ShopDetailViewModel.swift */; }; 7CED91D82EF2D1F900457128 /* ClassComponentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED908A2EF2D1F900457128 /* ClassComponentView.swift */; }; 7CED91DD2EF2D1F900457128 /* ShopDetailTableViewNameCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FE02EF2D1F900457128 /* ShopDetailTableViewNameCell.swift */; }; 7CED91DF2EF2D1F900457128 /* NotiViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F152EF2D1F900457128 /* NotiViewController.swift */; }; 7CED91E32EF2D1F900457128 /* AddClassCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90962EF2D1F900457128 /* AddClassCollectionViewCell.swift */; }; - 7CED91E52EF2D1F900457128 /* ModifyUserModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8F222EF2D1F900457128 /* ModifyUserModalViewController.swift */; }; 7CED91E62EF2D1F900457128 /* ShopSummaryInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FC82EF2D1F900457128 /* ShopSummaryInfoView.swift */; }; 7CED91E92EF2D1F900457128 /* ShopDetailTableViewDeliveryTipsCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED8FE32EF2D1F900457128 /* ShopDetailTableViewDeliveryTipsCell.swift */; }; 7CED91EA2EF2D1F900457128 /* TimetableCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CED90992EF2D1F900457128 /* TimetableCollectionView.swift */; }; @@ -680,7 +688,6 @@ 8354BE522C7A0566009D4D7A /* CoreDataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8354BE512C7A0566009D4D7A /* CoreDataService.swift */; }; 835C99172C7DF89A002E02D3 /* DeleteNotificationKeywordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835C99162C7DF89A002E02D3 /* DeleteNotificationKeywordUseCase.swift */; }; 835C991A2C7EDB63002E02D3 /* FetchNotificationKeywordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835C99192C7EDB63002E02D3 /* FetchNotificationKeywordUseCase.swift */; }; - 835C991E2C7F1256002E02D3 /* ModalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835C991D2C7F1256002E02D3 /* ModalViewController.swift */; }; 835DC1F92C849ECD00488506 /* FetchRecommendedKeywordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835DC1F82C849ECD00488506 /* FetchRecommendedKeywordUseCase.swift */; }; 835DC1FB2C84B96500488506 /* FetchHotSearchingKeywordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835DC1FA2C84B96500488506 /* FetchHotSearchingKeywordUseCase.swift */; }; 835DC2042C84C39E00488506 /* ManageRecentSearchedWordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 835DC2032C84C39E00488506 /* ManageRecentSearchedWordUseCase.swift */; }; @@ -728,6 +735,16 @@ 83E96DB82BBC6FBF00164914 /* BusSearchDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E96DB72BBC6FBF00164914 /* BusSearchDto.swift */; }; 83EBB87A2D0FDFD700346018 /* KoinPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */; }; 83EBB87E2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */; }; + AA20000000000000000001 /* PublisherTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000001 /* PublisherTestSupport.swift */; }; + AA20000000000000000002 /* DiningFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000002 /* DiningFixtures.swift */; }; + AA20000000000000000003 /* SpyDiningRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000003 /* SpyDiningRepository.swift */; }; + AA20000000000000000004 /* DateProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000004 /* DateProviderTests.swift */; }; + AA20000000000000000005 /* FetchDiningListUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000005 /* FetchDiningListUseCaseTests.swift */; }; + AA20000000000000000006 /* ShareMenuListUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000006 /* ShareMenuListUseCaseTests.swift */; }; + AA20000000000000000007 /* DiningLoggingTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000007 /* DiningLoggingTestSupport.swift */; }; + AA20000000000000000008 /* DiningViewModelStubs.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000008 /* DiningViewModelStubs.swift */; }; + AA20000000000000000009 /* DiningLoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000009 /* DiningLoggingTests.swift */; }; + AA20000000000000000010 /* DiningTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA10000000000000000010 /* DiningTypeTests.swift */; }; B438AEE42E756AB600E889C4 /* OrderImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B438AEE32E756AB600E889C4 /* OrderImage.swift */; }; B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC12E76CB7300A8E797 /* ShopSummaryDto.swift */; }; B46B8CC42E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B46B8CC32E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift */; }; @@ -740,9 +757,6 @@ B47839052E70FAFE00D002E3 /* OrderShopSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47839042E70FAFE00D002E3 /* OrderShopSummary.swift */; }; B47839072E70FF2B00D002E3 /* OrderShopMenusGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47839062E70FF2B00D002E3 /* OrderShopMenusGroups.swift */; }; B4B3EB262E6E7CAD00F8A23A /* OrderShopMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B3EB252E6E7CAD00F8A23A /* OrderShopMenus.swift */; }; - B608F0E62EB5DC2D0006F355 /* OrderHistoryCustomSearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B608F0E42EB5DC2C0006F355 /* OrderHistoryCustomSearchBar.swift */; }; - B608F0E72EB5DC2D0006F355 /* FilteringButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B608F0E32EB5DC2C0006F355 /* FilteringButton.swift */; }; - B608F0E82EB5DC2D0006F355 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B608F0E22EB5DC2C0006F355 /* EmptyStateView.swift */; }; B60D25462ED72017000E57B7 /* RadioButtonState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D25442ED72017000E57B7 /* RadioButtonState.swift */; }; B60D25472ED72017000E57B7 /* RadioButtonGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D25432ED72017000E57B7 /* RadioButtonGroup.swift */; }; B60D25482ED72017000E57B7 /* RadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D25412ED72017000E57B7 /* RadioButton.swift */; }; @@ -755,6 +769,15 @@ B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */; }; B6A0D62A2EEB01E600A19521 /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D6292EEB01E600A19521 /* Debug.xcconfig */; }; B6A0D62C2EEB01F100A19521 /* Release.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = B6A0D62B2EEB01F100A19521 /* Release.xcconfig */; }; + D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */; }; + D19A00013000000000000002 /* NotificationRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000001 /* NotificationRowModel.swift */; }; + D19A00013000000000000004 /* NotificationListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000003 /* NotificationListView.swift */; }; + D19A00013000000000000006 /* NotificationRowModel+NotificationHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A00013000000000000005 /* NotificationRowModel+NotificationHistoryItem.swift */; }; + D19A10013000000000000002 /* ChatListModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000001 /* ChatListModel.swift */; }; + D19A10013000000000000004 /* ChatMessageRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000003 /* ChatMessageRowModel.swift */; }; + D19A10013000000000000006 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000005 /* ChatListView.swift */; }; + D19A10013000000000000008 /* ChatInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000007 /* ChatInputView.swift */; }; + D19A1001300000000000000A /* ChatListModel+CallVanChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19A10013000000000000009 /* ChatListModel+CallVanChat.swift */; }; D2009F762C17270F00211D1B /* CalendarDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2009F752C17270F00211D1B /* CalendarDate.swift */; }; D2009F7F2C1784DA00211D1B /* MockAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2009F7E2C1784DA00211D1B /* MockAnalyticsService.swift */; }; D2078BF62BC4CCEB00A39861 /* EventDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2078BF52BC4CCEB00A39861 /* EventDto.swift */; }; @@ -900,9 +923,9 @@ D2A8F8BE2C4A78F20090C7A4 /* CoopShopData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A8F8BD2C4A78F20090C7A4 /* CoopShopData.swift */; }; D2A8F8C02C4BCD470090C7A4 /* ShareMenuListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A8F8BF2C4BCD470090C7A4 /* ShareMenuListUseCase.swift */; }; D2A8F8C22C4BD36C0090C7A4 /* ShareService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2A8F8C12C4BD36C0090C7A4 /* ShareService.swift */; }; - D2B2193B2D66626900EAF5B1 /* ChatHistoryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2193A2D66626900EAF5B1 /* ChatHistoryData.swift */; }; + D2B2193B2D66626900EAF5B1 /* LostItemChatHistoryData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2193A2D66626900EAF5B1 /* LostItemChatHistoryData.swift */; }; D2B2193D2D66828300EAF5B1 /* WebSocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2193C2D66828200EAF5B1 /* WebSocketManager.swift */; }; - D2B2193F2D66DDF900EAF5B1 /* ChatDateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2193E2D66DDF900EAF5B1 /* ChatDateInfo.swift */; }; + D2B2193F2D66DDF900EAF5B1 /* LostItemChatDateInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B2193E2D66DDF900EAF5B1 /* LostItemChatDateInfo.swift */; }; D2B7EBCA2D37C81B00EE46B0 /* PostLostItemRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B7EBC92D37C81B00EE46B0 /* PostLostItemRequest.swift */; }; D2B7EBDA2D382C8800EE46B0 /* UserTypeResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B7EBD92D382C8800EE46B0 /* UserTypeResponse.swift */; }; D2B7EBDC2D382D5400EE46B0 /* CheckAuthUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2B7EBDB2D382D5400EE46B0 /* CheckAuthUseCase.swift */; }; @@ -923,18 +946,14 @@ D2D422722BB2A7BE00A8AF04 /* Confirmable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D422712BB2A7BE00A8AF04 /* Confirmable.swift */; }; D2D422752BB2AB3A00A8AF04 /* PasswordConfirmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D422742BB2AB3A00A8AF04 /* PasswordConfirmer.swift */; }; D2D422772BB2EA3900A8AF04 /* FindPasswordRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D422762BB2EA3900A8AF04 /* FindPasswordRequest.swift */; }; - D2D462602D63A81500C60864 /* ChatRoomDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4625F2D63A81500C60864 /* ChatRoomDto.swift */; }; - D2D462622D63B90100C60864 /* ChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462612D63B90100C60864 /* ChatRepository.swift */; }; - D2D462662D63B98F00C60864 /* FetchChatRoomUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462652D63B98F00C60864 /* FetchChatRoomUseCase.swift */; }; - D2D4626A2D63BA5200C60864 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462692D63BA5200C60864 /* ChatService.swift */; }; - D2D4626C2D63BAC600C60864 /* ChatAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4626B2D63BAC600C60864 /* ChatAPI.swift */; }; - D2D4626E2D63BD1400C60864 /* DefaultChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4626D2D63BD1400C60864 /* DefaultChatRepository.swift */; }; - D2D462712D63C5ED00C60864 /* ChatRoomItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462702D63C5ED00C60864 /* ChatRoomItem.swift */; }; - D2D462732D63CACC00C60864 /* ChatDetailDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462722D63CACC00C60864 /* ChatDetailDto.swift */; }; - D2D462752D63CB3E00C60864 /* FetchChatDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462742D63CB3E00C60864 /* FetchChatDetailUseCase.swift */; }; - D2D4627A2D63D4D400C60864 /* BlockUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462792D63D4D400C60864 /* BlockUserUseCase.swift */; }; - D2D4627C2D6482AA00C60864 /* CreateChatRoomResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4627B2D6482AA00C60864 /* CreateChatRoomResponse.swift */; }; - D2D4627E2D64835B00C60864 /* CreateChatRoomUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4627D2D64835A00C60864 /* CreateChatRoomUseCase.swift */; }; + D2D462602D63A81500C60864 /* LostItemChatRoomDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4625F2D63A81500C60864 /* LostItemChatRoomDto.swift */; }; + D2D462662D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462652D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift */; }; + D2D462712D63C5ED00C60864 /* LostItemChatRoomItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462702D63C5ED00C60864 /* LostItemChatRoomItem.swift */; }; + D2D462732D63CACC00C60864 /* LostItemChatDetailDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462722D63CACC00C60864 /* LostItemChatDetailDto.swift */; }; + D2D462752D63CB3E00C60864 /* LostItemFetchChatDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462742D63CB3E00C60864 /* LostItemFetchChatDetailUseCase.swift */; }; + D2D4627A2D63D4D400C60864 /* LostItemBlockUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D462792D63D4D400C60864 /* LostItemBlockUserUseCase.swift */; }; + D2D4627C2D6482AA00C60864 /* LostItemCreateChatRoomResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4627B2D6482AA00C60864 /* LostItemCreateChatRoomResponse.swift */; }; + D2D4627E2D64835B00C60864 /* LostItemCreateChatRoomUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D4627D2D64835A00C60864 /* LostItemCreateChatRoomUseCase.swift */; }; D2D7C0D02C28285600C85A85 /* LandRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D7C0CF2C28285600C85A85 /* LandRepository.swift */; }; D2D7C0D52C28298500C85A85 /* FetchLandListUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D7C0D42C28298500C85A85 /* FetchLandListUseCase.swift */; }; D2D7C0D72C28299200C85A85 /* FetchLandDetailUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D7C0D62C28299200C85A85 /* FetchLandDetailUseCase.swift */; }; @@ -967,8 +986,6 @@ D2FFA9702D5F4DC500EF8E56 /* CheckLoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FFA96F2D5F4DB600EF8E56 /* CheckLoginUseCase.swift */; }; D2FFA9852D6333BB00EF8E56 /* ReportLostItemRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2FFA9842D6333BB00EF8E56 /* ReportLostItemRequest.swift */; }; D80AD4E42E6C6BC30061334B /* waveLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D80AD4E32E6C6BC30061334B /* waveLogo.json */; }; - D80AD4E52E6C6BC30061334B /* waveLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D80AD4E32E6C6BC30061334B /* waveLogo.json */; }; - D80AD4E62E6C6BC30061334B /* waveLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D80AD4E32E6C6BC30061334B /* waveLogo.json */; }; D837FB122E254BD8002BFB9F /* CustomSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D837FB112E254BD8002BFB9F /* CustomSessionManager.swift */; }; D85939752DC12C9D00CE4CB2 /* CheckDuplicatedIdUsecase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85939742DC12C9D00CE4CB2 /* CheckDuplicatedIdUsecase.swift */; }; D85939772DC12D0900CE4CB2 /* CheckDuplicatedIdRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85939762DC12D0900CE4CB2 /* CheckDuplicatedIdRequest.swift */; }; @@ -978,7 +995,6 @@ D874DB4D2DBF1A6C0098EED0 /* SendVerificationCodeUsecase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D874DB4C2DBF1A6B0098EED0 /* SendVerificationCodeUsecase.swift */; }; D874DB4F2DBF1FEE0098EED0 /* SendVerificationCodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D874DB4E2DBF1FEE0098EED0 /* SendVerificationCodeRequest.swift */; }; D874DB512DBF200C0098EED0 /* SendVerificationCodeDto.swift in Sources */ = {isa = PBXBuildFile; fileRef = D874DB502DBF200C0098EED0 /* SendVerificationCodeDto.swift */; }; - D891F25F2E45DC9D006D1F41 /* TrackPaddedSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D891F25E2E45DC9D006D1F41 /* TrackPaddedSlider.swift */; }; D89BAA392DCA5B3D006D3BB7 /* GeneralRegisterFormRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89BAA382DCA5B3D006D3BB7 /* GeneralRegisterFormRequest.swift */; }; D89BAA3B2DCA5B47006D3BB7 /* StudentRegisterFormRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89BAA3A2DCA5B47006D3BB7 /* StudentRegisterFormRequest.swift */; }; D89BAA3D2DCA6483006D3BB7 /* RegisterFormUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D89BAA3C2DCA6483006D3BB7 /* RegisterFormUseCase.swift */; }; @@ -987,13 +1003,13 @@ D8B1A9EC2E321E7800943535 /* ShopSortType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B1A9EB2E321E7800943535 /* ShopSortType.swift */; }; D8B4E0022DBFAB95001FBC89 /* CheckVerificationCodeUsecase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B4E0012DBFAB95001FBC89 /* CheckVerificationCodeUsecase.swift */; }; D8B4E0042DBFAD1A001FBC89 /* CheckVerificationCodeRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B4E0032DBFAD1A001FBC89 /* CheckVerificationCodeRequest.swift */; }; - D8F5C54A2E6EF76800FB6708 /* OrderFloatingButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F5C5492E6EF76800FB6708 /* OrderFloatingButton.swift */; }; D8F5C54C2E6F012500FB6708 /* LottieAnimationManageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */; }; - D8F5C5542E6F144300FB6708 /* floatingLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D8F5C5532E6F144300FB6708 /* floatingLogo.json */; }; - D8F5C5552E6F144300FB6708 /* floatingLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D8F5C5532E6F144300FB6708 /* floatingLogo.json */; }; D8F5C5562E6F144300FB6708 /* floatingLogo.json in Resources */ = {isa = PBXBuildFile; fileRef = D8F5C5532E6F144300FB6708 /* floatingLogo.json */; }; F1A000010000000000000001 /* ZoomingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000002 /* ZoomingCollectionView.swift */; }; F1A000010000000000000003 /* ZoomingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */; }; + FE01000000000000000021 /* KoinDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000011 /* KoinDropdown.swift */; }; + FE01000000000000000023 /* KoinDropdownAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000013 /* KoinDropdownAnimator.swift */; }; + FE01000000000000000026 /* KoinDropdownConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE01000000000000000016 /* KoinDropdownConfiguration.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1004,14 +1020,7 @@ remoteGlobalIDString = 839D71152BEB1570001BC7F7; remoteInfo = NotificationService; }; - A001E2C52845091F00D6C310 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = A001E2AC2845091D00D6C310 /* Project object */; - proxyType = 1; - remoteGlobalIDString = A001E2B32845091D00D6C310; - remoteInfo = koin; - }; - A001E2CF2845091F00D6C310 /* PBXContainerItemProxy */ = { + EC692289302700A400EE26ED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = A001E2AC2845091D00D6C310 /* Project object */; proxyType = 1; @@ -1086,6 +1095,13 @@ 7C17EEAF2EBF708B008BCA89 /* ShopSearchDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopSearchDto.swift; sourceTree = ""; }; 7C17EEB12EBF7175008BCA89 /* FetchSearchShopUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchSearchShopUseCase.swift; sourceTree = ""; }; 7C281CFC2EB3ED4B00BD6B4E /* FetchOrderShopDetailFromShopUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOrderShopDetailFromShopUseCase.swift; sourceTree = ""; }; + 7C333E7B302E285B009D2B89 /* KoinModalAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinModalAnimator.swift; sourceTree = ""; }; + 7C333E7D302E28AA009D2B89 /* KoinModalPresentationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinModalPresentationController.swift; sourceTree = ""; }; + 7C333E9230301B3D009D2B89 /* KoinModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinModalViewController.swift; sourceTree = ""; }; + 7C333E9430301CB7009D2B89 /* KoinModalConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinModalConfiguration.swift; sourceTree = ""; }; + 7C333E9B303026B6009D2B89 /* KoinModalStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinModalStyle.swift; sourceTree = ""; }; + 7C333EA230303678009D2B89 /* ModalContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalContentView.swift; sourceTree = ""; }; + 7C333EA43030429B009D2B89 /* ModalButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalButtonView.swift; sourceTree = ""; }; 7C372ED82F1B7F3900149729 /* FetchLostItemListRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchLostItemListRequest.swift; sourceTree = ""; }; 7C372EDC2F1B8DC900149729 /* LostItemListFilterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemListFilterButton.swift; sourceTree = ""; }; 7C372EE52F1C0CC400149729 /* LostItemListData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemListData.swift; sourceTree = ""; }; @@ -1100,7 +1116,6 @@ 7C372F282F1D6A7F00149729 /* LostItemDataViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemDataViewModel.swift; sourceTree = ""; }; 7C372F2F2F1DB4C300149729 /* LostItemImagesCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemImagesCollectionView.swift; sourceTree = ""; }; 7C372F302F1DB4C300149729 /* LostItemImagesCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemImagesCollectionViewCell.swift; sourceTree = ""; }; - 7C372F382F1DCFF800149729 /* ModalViewControllerB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalViewControllerB.swift; sourceTree = ""; }; 7C3ECC792F41505000EE8F13 /* ErrorResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorResponse.swift; sourceTree = ""; }; 7C457AE62FCDE7240011D338 /* SwiftUIViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIViewModelProtocol.swift; sourceTree = ""; }; 7C457AFE2FCE1BA80011D338 /* CategoryFeaturedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFeaturedButton.swift; sourceTree = ""; }; @@ -1245,7 +1260,9 @@ 7C7BD71C302B2B0A003C5A15 /* AppImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppImage.swift; sourceTree = ""; }; 7C7BD71E302B2B37003C5A15 /* PostLostItemUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostLostItemUseCase.swift; sourceTree = ""; }; 7C7BD720302B2C25003C5A15 /* ReportLostItemUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportLostItemUseCase.swift; sourceTree = ""; }; - 7C7CCE492F834D4B00E3A54B /* CallVanModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanModalViewController.swift; sourceTree = ""; }; + 7C7BD722302B3D8F003C5A15 /* NoticeAISummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeAISummary.swift; sourceTree = ""; }; + 7C7BD724302B3E25003C5A15 /* NoticeAISummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeAISummaryView.swift; sourceTree = ""; }; + 7C7BD726302D8002003C5A15 /* NoticeAISummaryDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeAISummaryDto.swift; sourceTree = ""; }; 7C7CCE6F2F866E9400E3A54B /* AppPath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPath.swift; sourceTree = ""; }; 7C7CCE712F86863200E3A54B /* FetchCallVanRestrictionUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchCallVanRestrictionUseCase.swift; sourceTree = ""; }; 7C7CCE732F86866B00E3A54B /* CallVanRestriction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanRestriction.swift; sourceTree = ""; }; @@ -1257,8 +1274,12 @@ 7C7F43272F65F31500CC5860 /* CallVanReportRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanReportRequest.swift; sourceTree = ""; }; 7C7F43292F65F42300CC5860 /* CallVanRecruitmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanRecruitmentState.swift; sourceTree = ""; }; 7C7F56B32FFB6A6400847151 /* NotificationPopUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationPopUpViewController.swift; sourceTree = ""; }; - 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRecord.swift; sourceTree = ""; }; + 7C7F56B62FFB9FE000847151 /* NotificationHistoryRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryRecord.swift; sourceTree = ""; }; 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryService.swift; sourceTree = ""; }; + 7C8261963040344700C20F64 /* FilterGroupCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterGroupCollectionView.swift; sourceTree = ""; }; + 7C82619830403C5E00C20F64 /* FilterGroupCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterGroupCollectionViewCell.swift; sourceTree = ""; }; + 7C82619B30407FF300C20F64 /* FilterGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterGroupView.swift; sourceTree = ""; }; + 7C82619F3040C7E700C20F64 /* CallVanListRequest+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CallVanListRequest+.swift"; sourceTree = ""; }; 7C82FF772E9D18C7006335A7 /* OrderShopDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderShopDetail.swift; sourceTree = ""; }; 7C85D2742EBDE374005E63FF /* FetchOrderShopMenusAndGroupsFromShopUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOrderShopMenusAndGroupsFromShopUseCase.swift; sourceTree = ""; }; 7C86750D30061AA0003CB942 /* CheckHasUnreadNotificationHistoryUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckHasUnreadNotificationHistoryUseCase.swift; sourceTree = ""; }; @@ -1271,7 +1292,7 @@ 7C8A00102FD200000011D338 /* DiningIndicatorChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningIndicatorChip.swift; sourceTree = ""; }; 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewModel.swift; sourceTree = ""; }; - 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEmptyBackgroundView.swift; sourceTree = ""; }; + 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationEmptyView.swift; sourceTree = ""; }; 7C8A94242FD0A97400DEA6F5 /* NotificationFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFooterView.swift; sourceTree = ""; }; 7C8A94252FD0A97400DEA6F5 /* NotificationTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableView.swift; sourceTree = ""; }; 7C8A94262FD0A97400DEA6F5 /* NotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTableViewCell.swift; sourceTree = ""; }; @@ -1294,13 +1315,16 @@ 7C8ADD3D2F20C2C400F85BDE /* LostItemRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemRepository.swift; sourceTree = ""; }; 7C8ADD3F2F20C30700F85BDE /* DefaultLostItemRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultLostItemRepository.swift; sourceTree = ""; }; 7C8ADD442F20C3FB00F85BDE /* FetchLostItemListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchLostItemListUseCase.swift; sourceTree = ""; }; + 7C8B37A83040287D00A7EB5A /* FilterBottomSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterBottomSheetView.swift; sourceTree = ""; }; + 7C8B37AC3040289500A7EB5A /* FilterItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterItemModel.swift; sourceTree = ""; }; + 7C8B37AF304028B400A7EB5A /* FilterGroupModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterGroupModel.swift; sourceTree = ""; }; 7C8BFCC22FCFF47600963679 /* ActionBindableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBindableView.swift; sourceTree = ""; }; 7C8BFCD22FD0272900963679 /* View+border.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+border.swift"; sourceTree = ""; }; 7C8BFCD52FD032A000963679 /* View+linespacing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+linespacing.swift"; sourceTree = ""; }; 7C8BFCDC2FD04B8000963679 /* CategoryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryModels.swift; sourceTree = ""; }; 7C8BFCDD2FD04B8000963679 /* HomeDiningItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeDiningItem.swift; sourceTree = ""; }; 7C8BFCDE2FD04B8000963679 /* HomeHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeHeader.swift; sourceTree = ""; }; - 7C8BFCE02FD04B8000963679 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = ""; }; + 7C8BFCE02FD04B8000963679 /* NotificationHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationHistoryItem.swift; sourceTree = ""; }; 7C8BFCE62FD04BA000963679 /* FetchHomeDiningListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchHomeDiningListUseCase.swift; sourceTree = ""; }; 7C8BFCE72FD04BA000963679 /* FetchHeaderUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchHeaderUseCase.swift; sourceTree = ""; }; 7C8BFCE82FD04BA000963679 /* FetchHomeCountsUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchHomeCountsUseCase.swift; sourceTree = ""; }; @@ -1378,7 +1402,6 @@ 7CC42B1D2F56F3B200940CE1 /* CallVanButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanButton.swift; sourceTree = ""; }; 7CC42B1F2F56F3C400940CE1 /* CallVanState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanState.swift; sourceTree = ""; }; 7CC42B212F56F9C100940CE1 /* CallVanList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanList.swift; sourceTree = ""; }; - 7CC42B272F575E8100940CE1 /* CallVanListFilterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanListFilterViewController.swift; sourceTree = ""; }; 7CC42B292F575F4D00940CE1 /* CallVanFilterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanFilterButton.swift; sourceTree = ""; }; 7CC42B2E2F58105B00940CE1 /* CallVanBottomSheetViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanBottomSheetViewController.swift; sourceTree = ""; }; 7CC42B322F58247D00940CE1 /* CallVanNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanNotification.swift; sourceTree = ""; }; @@ -1406,12 +1429,12 @@ 7CC42B882F5D85F700940CE1 /* CallVanChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChat.swift; sourceTree = ""; }; 7CC42B8E2F5DCE7400940CE1 /* CallVanChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatViewController.swift; sourceTree = ""; }; 7CC42B902F5DCE7D00940CE1 /* CallVanChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatViewModel.swift; sourceTree = ""; }; - 7CC42B932F5DCEA600940CE1 /* CallVanChatTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatTableView.swift; sourceTree = ""; }; - 7CC42B952F5DCEB300940CE1 /* CallVanChatDateHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatDateHeaderView.swift; sourceTree = ""; }; - 7CC42B972F5E812F00940CE1 /* CallVanChatLeftCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatLeftCell.swift; sourceTree = ""; }; - 7CC42B9B2F5E834E00940CE1 /* CallVanChatRightCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallVanChatRightCell.swift; sourceTree = ""; }; - 7CCB1E4A2F29D87C00472669 /* PostChatDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostChatDetailUseCase.swift; sourceTree = ""; }; - 7CCB1E4D2F29DB3200472669 /* PostChatDetailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostChatDetailRequest.swift; sourceTree = ""; }; + 7CC42B932F5DCEA600940CE1 /* ChatTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTableView.swift; sourceTree = ""; }; + 7CC42B952F5DCEB300940CE1 /* ChatDateHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDateHeaderView.swift; sourceTree = ""; }; + 7CC42B972F5E812F00940CE1 /* ChatLeftCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatLeftCell.swift; sourceTree = ""; }; + 7CC42B9B2F5E834E00940CE1 /* ChatRightCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRightCell.swift; sourceTree = ""; }; + 7CCB1E4A2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemPostChatDetailUseCase.swift; sourceTree = ""; }; + 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemPostChatDetailRequest.swift; sourceTree = ""; }; 7CD411E92FEB92280079467B /* HomeTabBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTabBarItem.swift; sourceTree = ""; }; 7CEB3B4A2FBFF4B700AF2B81 /* DeleteDeviceTokenUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteDeviceTokenUseCase.swift; sourceTree = ""; }; 7CED8EC32EF2D1F900457128 /* ShopCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopCollectionView.swift; sourceTree = ""; }; @@ -1420,7 +1443,6 @@ 7CED8ED42EF2D1F900457128 /* ForceModifyUserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceModifyUserViewController.swift; sourceTree = ""; }; 7CED8EDF2EF2D1F900457128 /* ForceUpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceUpdateViewModel.swift; sourceTree = ""; }; 7CED8EE02EF2D1F900457128 /* ForceUpdateViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceUpdateViewController.swift; sourceTree = ""; }; - 7CED8EE12EF2D1F900457128 /* UpdateModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateModalViewController.swift; sourceTree = ""; }; 7CED8EE52EF2D1F900457128 /* ReportLostItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportLostItemViewModel.swift; sourceTree = ""; }; 7CED8EE62EF2D1F900457128 /* ReportLostItemViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportLostItemViewController.swift; sourceTree = ""; }; 7CED8EE82EF2D1F900457128 /* LostArticleImageCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostArticleImageCollectionView.swift; sourceTree = ""; }; @@ -1431,15 +1453,15 @@ 7CED8EEF2EF2D1F900457128 /* DatePickerDropdownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatePickerDropdownView.swift; sourceTree = ""; }; 7CED8EF12EF2D1F900457128 /* PostLostItemViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostLostItemViewModel.swift; sourceTree = ""; }; 7CED8EF22EF2D1F900457128 /* PostLostItemViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostLostItemViewController.swift; sourceTree = ""; }; - 7CED8EF92EF2D1F900457128 /* ChatListTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListTableViewModel.swift; sourceTree = ""; }; - 7CED8EFA2EF2D1F900457128 /* ChatListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListTableViewController.swift; sourceTree = ""; }; - 7CED8EFC2EF2D1F900457128 /* ChatHistoryTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHistoryTableView.swift; sourceTree = ""; }; - 7CED8EFD2EF2D1F900457128 /* ChatTextTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTextTableViewCell.swift; sourceTree = ""; }; - 7CED8EFE2EF2D1F900457128 /* ChatImageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatImageTableViewCell.swift; sourceTree = ""; }; - 7CED8EFF2EF2D1F900457128 /* ChatDateHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDateHeaderView.swift; sourceTree = ""; }; - 7CED8F012EF2D1F900457128 /* BlockCheckModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockCheckModalViewController.swift; sourceTree = ""; }; - 7CED8F022EF2D1F900457128 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = ""; }; - 7CED8F032EF2D1F900457128 /* ChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewController.swift; sourceTree = ""; }; + 7CED8EF92EF2D1F900457128 /* LostItemChatListTableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatListTableViewModel.swift; sourceTree = ""; }; + 7CED8EFA2EF2D1F900457128 /* LostItemChatListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatListTableViewController.swift; sourceTree = ""; }; + 7CED8EFC2EF2D1F900457128 /* LostItemChatHistoryTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatHistoryTableView.swift; sourceTree = ""; }; + 7CED8EFD2EF2D1F900457128 /* LostItemChatTextTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatTextTableViewCell.swift; sourceTree = ""; }; + 7CED8EFE2EF2D1F900457128 /* LostItemChatImageTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatImageTableViewCell.swift; sourceTree = ""; }; + 7CED8EFF2EF2D1F900457128 /* LostItemChatDateHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatDateHeaderView.swift; sourceTree = ""; }; + 7CED8F012EF2D1F900457128 /* LostItemBlockCheckModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemBlockCheckModalViewController.swift; sourceTree = ""; }; + 7CED8F022EF2D1F900457128 /* LostItemChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatViewModel.swift; sourceTree = ""; }; + 7CED8F032EF2D1F900457128 /* LostItemChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatViewController.swift; sourceTree = ""; }; 7CED8F072EF2D1F900457128 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; 7CED8F082EF2D1F900457128 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 7CED8F0A2EF2D1F900457128 /* ChangeMyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeMyProfileViewController.swift; sourceTree = ""; }; @@ -1456,7 +1478,6 @@ 7CED8F1A2EF2D1F900457128 /* PolicyListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolicyListCollectionViewCell.swift; sourceTree = ""; }; 7CED8F1B2EF2D1F900457128 /* PolicyListTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolicyListTableView.swift; sourceTree = ""; }; 7CED8F1E2EF2D1F900457128 /* PolicyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolicyViewController.swift; sourceTree = ""; }; - 7CED8F222EF2D1F900457128 /* ModifyUserModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyUserModalViewController.swift; sourceTree = ""; }; 7CED8F232EF2D1F900457128 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; 7CED8F242EF2D1F900457128 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 7CED8F262EF2D1F900457128 /* AgreementFormViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementFormViewController.swift; sourceTree = ""; }; @@ -1551,11 +1572,8 @@ 7CED90052EF2D1F900457128 /* ReviewListCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewListCollectionView.swift; sourceTree = ""; }; 7CED90062EF2D1F900457128 /* ReviewListCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewListCollectionViewCell.swift; sourceTree = ""; }; 7CED90072EF2D1F900457128 /* ReviewListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewListHeaderView.swift; sourceTree = ""; }; - 7CED90082EF2D1F900457128 /* ImageDropDownCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageDropDownCell.swift; sourceTree = ""; }; 7CED900A2EF2D1F900457128 /* ScoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreView.swift; sourceTree = ""; }; 7CED900B2EF2D1F900457128 /* NonReviewListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonReviewListView.swift; sourceTree = ""; }; - 7CED900C2EF2D1F900457128 /* ReviewLoginModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewLoginModalViewController.swift; sourceTree = ""; }; - 7CED900D2EF2D1F900457128 /* DeleteReviewModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteReviewModalViewController.swift; sourceTree = ""; }; 7CED900F2EF2D1F900457128 /* ReviewListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewListViewController.swift; sourceTree = ""; }; 7CED90102EF2D1F900457128 /* ReviewListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewListViewModel.swift; sourceTree = ""; }; 7CED90122EF2D1F900457128 /* DashedBorderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashedBorderButton.swift; sourceTree = ""; }; @@ -1566,7 +1584,6 @@ 7CED90182EF2D1F900457128 /* ReviewImageUploadCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewImageUploadCollectionViewCell.swift; sourceTree = ""; }; 7CED901B2EF2D1F900457128 /* ShopReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopReviewViewModel.swift; sourceTree = ""; }; 7CED901C2EF2D1F900457128 /* ShopReviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopReviewViewController.swift; sourceTree = ""; }; - 7CED901D2EF2D1F900457128 /* BackButtonPopUpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackButtonPopUpViewController.swift; sourceTree = ""; }; 7CED901F2EF2D1F900457128 /* ReportDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportDetailView.swift; sourceTree = ""; }; 7CED90212EF2D1F900457128 /* ShopReviewReportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopReviewReportViewModel.swift; sourceTree = ""; }; 7CED90222EF2D1F900457128 /* ShopReviewReportViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopReviewReportViewController.swift; sourceTree = ""; }; @@ -1689,7 +1706,6 @@ 8354BE512C7A0566009D4D7A /* CoreDataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataService.swift; sourceTree = ""; }; 835C99162C7DF89A002E02D3 /* DeleteNotificationKeywordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteNotificationKeywordUseCase.swift; sourceTree = ""; }; 835C99192C7EDB63002E02D3 /* FetchNotificationKeywordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchNotificationKeywordUseCase.swift; sourceTree = ""; }; - 835C991D2C7F1256002E02D3 /* ModalViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalViewController.swift; sourceTree = ""; }; 835DC1F82C849ECD00488506 /* FetchRecommendedKeywordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchRecommendedKeywordUseCase.swift; sourceTree = ""; }; 835DC1FA2C84B96500488506 /* FetchHotSearchingKeywordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchHotSearchingKeywordUseCase.swift; sourceTree = ""; }; 835DC2032C84C39E00488506 /* ManageRecentSearchedWordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageRecentSearchedWordUseCase.swift; sourceTree = ""; }; @@ -1739,10 +1755,16 @@ 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinPickerView.swift; sourceTree = ""; }; 83EBB87D2D0FE1BE00346018 /* FetchKoinPickerDateUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchKoinPickerDateUseCase.swift; sourceTree = ""; }; A001E2B42845091D00D6C310 /* koin.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = koin.app; sourceTree = BUILT_PRODUCTS_DIR; }; - A001E2C42845091F00D6C310 /* koinTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - A001E2CE2845091F00D6C310 /* koinUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - A001E2D22845091F00D6C310 /* koinUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = koinUITests.swift; sourceTree = ""; }; - A001E2D42845091F00D6C310 /* koinUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = koinUITestsLaunchTests.swift; sourceTree = ""; }; + AA10000000000000000001 /* PublisherTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherTestSupport.swift; sourceTree = ""; }; + AA10000000000000000002 /* DiningFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningFixtures.swift; sourceTree = ""; }; + AA10000000000000000003 /* SpyDiningRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpyDiningRepository.swift; sourceTree = ""; }; + AA10000000000000000004 /* DateProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateProviderTests.swift; sourceTree = ""; }; + AA10000000000000000005 /* FetchDiningListUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchDiningListUseCaseTests.swift; sourceTree = ""; }; + AA10000000000000000006 /* ShareMenuListUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareMenuListUseCaseTests.swift; sourceTree = ""; }; + AA10000000000000000007 /* DiningLoggingTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningLoggingTestSupport.swift; sourceTree = ""; }; + AA10000000000000000008 /* DiningViewModelStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningViewModelStubs.swift; sourceTree = ""; }; + AA10000000000000000009 /* DiningLoggingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningLoggingTests.swift; sourceTree = ""; }; + AA10000000000000000010 /* DiningTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiningTypeTests.swift; sourceTree = ""; }; B438AEE32E756AB600E889C4 /* OrderImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderImage.swift; sourceTree = ""; }; B46B8CC12E76CB7300A8E797 /* ShopSummaryDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopSummaryDto.swift; sourceTree = ""; }; B46B8CC32E76CC6300A8E797 /* FetchOrderShopSummaryFromShopUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchOrderShopSummaryFromShopUseCase.swift; sourceTree = ""; }; @@ -1755,9 +1777,6 @@ B47839042E70FAFE00D002E3 /* OrderShopSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderShopSummary.swift; sourceTree = ""; }; B47839062E70FF2B00D002E3 /* OrderShopMenusGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderShopMenusGroups.swift; sourceTree = ""; }; B4B3EB252E6E7CAD00F8A23A /* OrderShopMenus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderShopMenus.swift; sourceTree = ""; }; - B608F0E22EB5DC2C0006F355 /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; - B608F0E32EB5DC2C0006F355 /* FilteringButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilteringButton.swift; sourceTree = ""; }; - B608F0E42EB5DC2C0006F355 /* OrderHistoryCustomSearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderHistoryCustomSearchBar.swift; sourceTree = ""; }; B60D25412ED72017000E57B7 /* RadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButton.swift; sourceTree = ""; }; B60D25422ED72017000E57B7 /* RadioButtonColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButtonColors.swift; sourceTree = ""; }; B60D25432ED72017000E57B7 /* RadioButtonGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RadioButtonGroup.swift; sourceTree = ""; }; @@ -1771,6 +1790,15 @@ B68D2CEA2EA50C1E00F3B479 /* UIViewController+Toast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Toast.swift"; sourceTree = ""; }; B6A0D6292EEB01E600A19521 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; B6A0D62B2EEB01F100A19521 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownHost.swift; sourceTree = ""; }; + D19A00013000000000000001 /* NotificationRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRowModel.swift; sourceTree = ""; }; + D19A00013000000000000003 /* NotificationListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListView.swift; sourceTree = ""; }; + D19A00013000000000000005 /* NotificationRowModel+NotificationHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NotificationRowModel+NotificationHistoryItem.swift"; sourceTree = ""; }; + D19A10013000000000000001 /* ChatListModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListModel.swift; sourceTree = ""; }; + D19A10013000000000000003 /* ChatMessageRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageRowModel.swift; sourceTree = ""; }; + D19A10013000000000000005 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = ""; }; + D19A10013000000000000007 /* ChatInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInputView.swift; sourceTree = ""; }; + D19A10013000000000000009 /* ChatListModel+CallVanChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatListModel+CallVanChat.swift"; sourceTree = ""; }; D2009F752C17270F00211D1B /* CalendarDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDate.swift; sourceTree = ""; }; D2009F7E2C1784DA00211D1B /* MockAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalyticsService.swift; sourceTree = ""; }; D2078BF52BC4CCEB00A39861 /* EventDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDto.swift; sourceTree = ""; }; @@ -1918,9 +1946,9 @@ D2A8F8BD2C4A78F20090C7A4 /* CoopShopData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoopShopData.swift; sourceTree = ""; }; D2A8F8BF2C4BCD470090C7A4 /* ShareMenuListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareMenuListUseCase.swift; sourceTree = ""; }; D2A8F8C12C4BD36C0090C7A4 /* ShareService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareService.swift; sourceTree = ""; }; - D2B2193A2D66626900EAF5B1 /* ChatHistoryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHistoryData.swift; sourceTree = ""; }; + D2B2193A2D66626900EAF5B1 /* LostItemChatHistoryData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatHistoryData.swift; sourceTree = ""; }; D2B2193C2D66828200EAF5B1 /* WebSocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketManager.swift; sourceTree = ""; }; - D2B2193E2D66DDF900EAF5B1 /* ChatDateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDateInfo.swift; sourceTree = ""; }; + D2B2193E2D66DDF900EAF5B1 /* LostItemChatDateInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatDateInfo.swift; sourceTree = ""; }; D2B7EBC92D37C81B00EE46B0 /* PostLostItemRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostLostItemRequest.swift; sourceTree = ""; }; D2B7EBD92D382C8800EE46B0 /* UserTypeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTypeResponse.swift; sourceTree = ""; }; D2B7EBDB2D382D5400EE46B0 /* CheckAuthUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckAuthUseCase.swift; sourceTree = ""; }; @@ -1942,18 +1970,14 @@ D2D422712BB2A7BE00A8AF04 /* Confirmable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Confirmable.swift; sourceTree = ""; }; D2D422742BB2AB3A00A8AF04 /* PasswordConfirmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordConfirmer.swift; sourceTree = ""; }; D2D422762BB2EA3900A8AF04 /* FindPasswordRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindPasswordRequest.swift; sourceTree = ""; }; - D2D4625F2D63A81500C60864 /* ChatRoomDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRoomDto.swift; sourceTree = ""; }; - D2D462612D63B90100C60864 /* ChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRepository.swift; sourceTree = ""; }; - D2D462652D63B98F00C60864 /* FetchChatRoomUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchChatRoomUseCase.swift; sourceTree = ""; }; - D2D462692D63BA5200C60864 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = ""; }; - D2D4626B2D63BAC600C60864 /* ChatAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatAPI.swift; sourceTree = ""; }; - D2D4626D2D63BD1400C60864 /* DefaultChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultChatRepository.swift; sourceTree = ""; }; - D2D462702D63C5ED00C60864 /* ChatRoomItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRoomItem.swift; sourceTree = ""; }; - D2D462722D63CACC00C60864 /* ChatDetailDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatDetailDto.swift; sourceTree = ""; }; - D2D462742D63CB3E00C60864 /* FetchChatDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchChatDetailUseCase.swift; sourceTree = ""; }; - D2D462792D63D4D400C60864 /* BlockUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockUserUseCase.swift; sourceTree = ""; }; - D2D4627B2D6482AA00C60864 /* CreateChatRoomResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateChatRoomResponse.swift; sourceTree = ""; }; - D2D4627D2D64835A00C60864 /* CreateChatRoomUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateChatRoomUseCase.swift; sourceTree = ""; }; + D2D4625F2D63A81500C60864 /* LostItemChatRoomDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatRoomDto.swift; sourceTree = ""; }; + D2D462652D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemFetchChatRoomUseCase.swift; sourceTree = ""; }; + D2D462702D63C5ED00C60864 /* LostItemChatRoomItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatRoomItem.swift; sourceTree = ""; }; + D2D462722D63CACC00C60864 /* LostItemChatDetailDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemChatDetailDto.swift; sourceTree = ""; }; + D2D462742D63CB3E00C60864 /* LostItemFetchChatDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemFetchChatDetailUseCase.swift; sourceTree = ""; }; + D2D462792D63D4D400C60864 /* LostItemBlockUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemBlockUserUseCase.swift; sourceTree = ""; }; + D2D4627B2D6482AA00C60864 /* LostItemCreateChatRoomResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemCreateChatRoomResponse.swift; sourceTree = ""; }; + D2D4627D2D64835A00C60864 /* LostItemCreateChatRoomUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LostItemCreateChatRoomUseCase.swift; sourceTree = ""; }; D2D7C0CF2C28285600C85A85 /* LandRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LandRepository.swift; sourceTree = ""; }; D2D7C0D42C28298500C85A85 /* FetchLandListUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchLandListUseCase.swift; sourceTree = ""; }; D2D7C0D62C28299200C85A85 /* FetchLandDetailUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchLandDetailUseCase.swift; sourceTree = ""; }; @@ -1995,7 +2019,6 @@ D874DB4C2DBF1A6B0098EED0 /* SendVerificationCodeUsecase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendVerificationCodeUsecase.swift; sourceTree = ""; }; D874DB4E2DBF1FEE0098EED0 /* SendVerificationCodeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendVerificationCodeRequest.swift; sourceTree = ""; }; D874DB502DBF200C0098EED0 /* SendVerificationCodeDto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendVerificationCodeDto.swift; sourceTree = ""; }; - D891F25E2E45DC9D006D1F41 /* TrackPaddedSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackPaddedSlider.swift; sourceTree = ""; }; D89BAA382DCA5B3D006D3BB7 /* GeneralRegisterFormRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralRegisterFormRequest.swift; sourceTree = ""; }; D89BAA3A2DCA5B47006D3BB7 /* StudentRegisterFormRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StudentRegisterFormRequest.swift; sourceTree = ""; }; D89BAA3C2DCA6483006D3BB7 /* RegisterFormUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterFormUseCase.swift; sourceTree = ""; }; @@ -2004,11 +2027,14 @@ D8B1A9EB2E321E7800943535 /* ShopSortType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopSortType.swift; sourceTree = ""; }; D8B4E0012DBFAB95001FBC89 /* CheckVerificationCodeUsecase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckVerificationCodeUsecase.swift; sourceTree = ""; }; D8B4E0032DBFAD1A001FBC89 /* CheckVerificationCodeRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckVerificationCodeRequest.swift; sourceTree = ""; }; - D8F5C5492E6EF76800FB6708 /* OrderFloatingButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderFloatingButton.swift; sourceTree = ""; }; D8F5C54B2E6F012500FB6708 /* LottieAnimationManageable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationManageable.swift; sourceTree = ""; }; D8F5C5532E6F144300FB6708 /* floatingLogo.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = floatingLogo.json; sourceTree = ""; }; + EC692285302700A400EE26ED /* koinUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = koinUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F1A000010000000000000002 /* ZoomingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionView.swift; sourceTree = ""; }; F1A000010000000000000004 /* ZoomingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomingCollectionViewCell.swift; sourceTree = ""; }; + FE01000000000000000011 /* KoinDropdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdown.swift; sourceTree = ""; }; + FE01000000000000000013 /* KoinDropdownAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownAnimator.swift; sourceTree = ""; }; + FE01000000000000000016 /* KoinDropdownConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KoinDropdownConfiguration.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2042,14 +2068,7 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A001E2C12845091F00D6C310 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - A001E2CB2845091F00D6C310 /* Frameworks */ = { + EC692282302700A400EE26ED /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( @@ -2191,9 +2210,48 @@ path = CoreData; sourceTree = ""; }; + 7C333E7A302E280C009D2B89 /* KoinModalViewController */ = { + isa = PBXGroup; + children = ( + 7C333E9F3030364F009D2B89 /* Subviews */, + 7C333E82302EC656009D2B89 /* Configuration */, + 7C333E7F302E317C009D2B89 /* Transition */, + 7C333E9230301B3D009D2B89 /* KoinModalViewController.swift */, + ); + path = KoinModalViewController; + sourceTree = ""; + }; + 7C333E7F302E317C009D2B89 /* Transition */ = { + isa = PBXGroup; + children = ( + 7C333E7B302E285B009D2B89 /* KoinModalAnimator.swift */, + 7C333E7D302E28AA009D2B89 /* KoinModalPresentationController.swift */, + ); + path = Transition; + sourceTree = ""; + }; + 7C333E82302EC656009D2B89 /* Configuration */ = { + isa = PBXGroup; + children = ( + 7C333E9430301CB7009D2B89 /* KoinModalConfiguration.swift */, + 7C333E9B303026B6009D2B89 /* KoinModalStyle.swift */, + ); + path = Configuration; + sourceTree = ""; + }; + 7C333E9F3030364F009D2B89 /* Subviews */ = { + isa = PBXGroup; + children = ( + 7C333EA230303678009D2B89 /* ModalContentView.swift */, + 7C333EA43030429B009D2B89 /* ModalButtonView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 7C372ED72F1B7F3100149729 /* LostItem */ = { isa = PBXGroup; children = ( + 7CCB1E4C2F29DB2500472669 /* LostItemChat */, 7C372ED82F1B7F3900149729 /* FetchLostItemListRequest.swift */, 7C8ADD2C2F20B43F00F85BDE /* UpdateLostItemRequest.swift */, D2FFA9842D6333BB00EF8E56 /* ReportLostItemRequest.swift */, @@ -2353,7 +2411,7 @@ 7C457B282FCE1BA80011D338 /* Notification */ = { isa = PBXGroup; children = ( - 7C8A94202FD0A95100DEA6F5 /* Subviews */, + D19A0001300000000000000A /* Support */, 7C8A941C2FD0A94F00DEA6F5 /* NotificationViewController.swift */, 7C8A941D2FD0A94F00DEA6F5 /* NotificationViewModel.swift */, ); @@ -2730,6 +2788,23 @@ path = Common; sourceTree = ""; }; + 7C82619A30407FD800C20F64 /* FilterGroupCollectionView */ = { + isa = PBXGroup; + children = ( + 7C8261963040344700C20F64 /* FilterGroupCollectionView.swift */, + 7C82619830403C5E00C20F64 /* FilterGroupCollectionViewCell.swift */, + ); + path = FilterGroupCollectionView; + sourceTree = ""; + }; + 7C82619E3040C7C100C20F64 /* Mapper */ = { + isa = PBXGroup; + children = ( + 7C82619F3040C7E700C20F64 /* CallVanListRequest+.swift */, + ); + path = Mapper; + sourceTree = ""; + }; 7C86751030062FF3003CB942 /* Core */ = { isa = PBXGroup; children = ( @@ -2747,7 +2822,7 @@ 7C8BFCDD2FD04B8000963679 /* HomeDiningItem.swift */, 7C8BFCDE2FD04B8000963679 /* HomeHeader.swift */, 7C8BFCF02FD04B800963679 /* HomeCounts.swift */, - 7C8BFCE02FD04B8000963679 /* NotificationItem.swift */, + 7C8BFCE02FD04B8000963679 /* NotificationHistoryItem.swift */, ); path = Home; sourceTree = ""; @@ -2766,14 +2841,15 @@ path = Home; sourceTree = ""; }; - 7C8A94202FD0A95100DEA6F5 /* Subviews */ = { + 7C8A94202FD0A95100DEA6F5 /* Views */ = { isa = PBXGroup; children = ( 7C8A94232FD0A96700DEA6F5 /* NotificationTableView */, - 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyBackgroundView.swift */, + 7C8A94212FD0A96400DEA6F5 /* NotificationEmptyView.swift */, + D19A00013000000000000003 /* NotificationListView.swift */, 7C7F56B32FFB6A6400847151 /* NotificationPopUpViewController.swift */, ); - path = Subviews; + path = Views; sourceTree = ""; }; 7C8A94232FD0A96700DEA6F5 /* NotificationTableView */ = { @@ -2797,6 +2873,7 @@ 7C8ADD362F20C0A500F85BDE /* LostItem */ = { isa = PBXGroup; children = ( + D2D4625E2D63A78000C60864 /* LostItemChat */, 7C8ADD372F20C0B200F85BDE /* LostItemListDto.swift */, 7C8ADD392F20C10500F85BDE /* LostItemListDataDto.swift */, 7C61D3142F212FB3005FAC3A /* LostItemDataDto.swift */, @@ -2821,10 +2898,51 @@ 7CB11C7F2FB32AF1004E0C80 /* UnsubscribeLostItemKeywordUseCase.swift */, 7C7BD71E302B2B37003C5A15 /* PostLostItemUseCase.swift */, 7C7BD720302B2C25003C5A15 /* ReportLostItemUseCase.swift */, + D2D462652D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift */, + D2D462742D63CB3E00C60864 /* LostItemFetchChatDetailUseCase.swift */, + D2D462792D63D4D400C60864 /* LostItemBlockUserUseCase.swift */, + D2D4627D2D64835A00C60864 /* LostItemCreateChatRoomUseCase.swift */, + 7CCB1E4A2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift */, ); path = LostItem; sourceTree = ""; }; + 7C8B2FF0303D928300A7EB5A /* Helper */ = { + isa = PBXGroup; + children = ( + FE01000000000000000013 /* KoinDropdownAnimator.swift */, + ); + path = Helper; + sourceTree = ""; + }; + 7C8B37A73040286C00A7EB5A /* FilterBottomSheet */ = { + isa = PBXGroup; + children = ( + 7C8B37AB3040288C00A7EB5A /* Model */, + 7C8B37AA3040288200A7EB5A /* Subviews */, + 7C8B37A83040287D00A7EB5A /* FilterBottomSheetView.swift */, + ); + path = FilterBottomSheet; + sourceTree = ""; + }; + 7C8B37AA3040288200A7EB5A /* Subviews */ = { + isa = PBXGroup; + children = ( + 7C82619B30407FF300C20F64 /* FilterGroupView.swift */, + 7C82619A30407FD800C20F64 /* FilterGroupCollectionView */, + ); + path = Subviews; + sourceTree = ""; + }; + 7C8B37AB3040288C00A7EB5A /* Model */ = { + isa = PBXGroup; + children = ( + 7C8B37AF304028B400A7EB5A /* FilterGroupModel.swift */, + 7C8B37AC3040289500A7EB5A /* FilterItemModel.swift */, + ); + path = Model; + sourceTree = ""; + }; 7CA0D6172EC744E700E9A282 /* ZoomedImageViewControllerB */ = { isa = PBXGroup; children = ( @@ -2878,6 +2996,9 @@ 7CA63A2F2F1B13F100226F86 /* LostItemStats.swift */, 7C372F262F1D644A00149729 /* LostItemData.swift */, 7CB11C7B2FB32817004E0C80 /* LostItemKeyword.swift */, + D2D462702D63C5ED00C60864 /* LostItemChatRoomItem.swift */, + D2B2193A2D66626900EAF5B1 /* LostItemChatHistoryData.swift */, + D2B2193E2D66DDF900EAF5B1 /* LostItemChatDateInfo.swift */, ); path = LostItem; sourceTree = ""; @@ -2917,7 +3038,7 @@ isa = PBXGroup; children = ( 7C7F56BA2FFBA18100847151 /* NotificationHistoryService.swift */, - 7C7F56B62FFB9FE000847151 /* NotificationRecord.swift */, + 7C7F56B62FFB9FE000847151 /* NotificationHistoryRecord.swift */, ); path = NotificationHistory; sourceTree = ""; @@ -3057,6 +3178,7 @@ 7CC42B092F56AB0100940CE1 /* CallVanList */ = { isa = PBXGroup; children = ( + 7C82619E3040C7C100C20F64 /* Mapper */, 7CC42B0F2F56AB7E00940CE1 /* Subviews */, 7CC42B0D2F56AB7900940CE1 /* CallVanListViewController.swift */, 7CC42B432F5991AE00940CE1 /* CallVanListViewModel.swift */, @@ -3068,10 +3190,7 @@ isa = PBXGroup; children = ( 7CC42B262F575E6900940CE1 /* CallVanListCollectionView */, - 7CC42B292F575F4D00940CE1 /* CallVanFilterButton.swift */, - 7CC42B272F575E8100940CE1 /* CallVanListFilterViewController.swift */, 7CC42B2E2F58105B00940CE1 /* CallVanBottomSheetViewController.swift */, - 7C7CCE492F834D4B00E3A54B /* CallVanModalViewController.swift */, ); path = Subviews; sourceTree = ""; @@ -3178,6 +3297,7 @@ 7CC42B5B2F59CFD600940CE1 /* CallVanPostTimeView.swift */, 7CC42B5F2F59D06C00940CE1 /* CallVanPostParticipantsView.swift */, 7CC42B7A2F5CBD3B00940CE1 /* CallVanPostPlaceBottomSheetView.swift */, + 7CC42B292F575F4D00940CE1 /* CallVanFilterButton.swift */, ); path = Subviews; sourceTree = ""; @@ -3212,38 +3332,38 @@ 7CC42B8A2F5DCE4800940CE1 /* CallVanChat */ = { isa = PBXGroup; children = ( - 7CC42B8B2F5DCE5700940CE1 /* Subviews */, + 7CC42B8B2F5DCE5700940CE1 /* Support */, 7CC42B8E2F5DCE7400940CE1 /* CallVanChatViewController.swift */, 7CC42B902F5DCE7D00940CE1 /* CallVanChatViewModel.swift */, ); path = CallVanChat; sourceTree = ""; }; - 7CC42B8B2F5DCE5700940CE1 /* Subviews */ = { + 7CC42B8B2F5DCE5700940CE1 /* Support */ = { isa = PBXGroup; children = ( - 7CC42B922F5DCE9800940CE1 /* CallVanChatTableView */, + D19A10013000000000000009 /* ChatListModel+CallVanChat.swift */, ); - path = Subviews; + path = Support; sourceTree = ""; }; - 7CC42B922F5DCE9800940CE1 /* CallVanChatTableView */ = { + 7CC42B922F5DCE9800940CE1 /* ChatTableView */ = { isa = PBXGroup; children = ( - 7CC42B932F5DCEA600940CE1 /* CallVanChatTableView.swift */, - 7CC42B952F5DCEB300940CE1 /* CallVanChatDateHeaderView.swift */, - 7CC42B972F5E812F00940CE1 /* CallVanChatLeftCell.swift */, - 7CC42B9B2F5E834E00940CE1 /* CallVanChatRightCell.swift */, + 7CC42B932F5DCEA600940CE1 /* ChatTableView.swift */, + 7CC42B952F5DCEB300940CE1 /* ChatDateHeaderView.swift */, + 7CC42B972F5E812F00940CE1 /* ChatLeftCell.swift */, + 7CC42B9B2F5E834E00940CE1 /* ChatRightCell.swift */, ); - path = CallVanChatTableView; + path = ChatTableView; sourceTree = ""; }; - 7CCB1E4C2F29DB2500472669 /* Chat */ = { + 7CCB1E4C2F29DB2500472669 /* LostItemChat */ = { isa = PBXGroup; children = ( - 7CCB1E4D2F29DB3200472669 /* PostChatDetailRequest.swift */, + 7CCB1E4D2F29DB3200472669 /* LostItemPostChatDetailRequest.swift */, ); - path = Chat; + path = LostItemChat; sourceTree = ""; }; 7CED8EC52EF2D1F900457128 /* ShopCollectionView */ = { @@ -3260,7 +3380,6 @@ children = ( 7CED8EDF2EF2D1F900457128 /* ForceUpdateViewModel.swift */, 7CED8EE02EF2D1F900457128 /* ForceUpdateViewController.swift */, - 7CED8EE12EF2D1F900457128 /* UpdateModalViewController.swift */, ); path = ForceUpdate; sourceTree = ""; @@ -3323,48 +3442,41 @@ 7CED8EF32EF2D1F900457128 /* PostLostItem */, 7C61D3372F21FADF005FAC3A /* EditLostItem */, 7CB11C162FB1CFED004E0C80 /* LostItemKeyword */, + 7CED8EFB2EF2D1F900457128 /* LostItemChatList */, + 7CED8F042EF2D1F900457128 /* LostItemChat */, ); path = LostItem; sourceTree = ""; }; - 7CED8EFB2EF2D1F900457128 /* ChatList */ = { - isa = PBXGroup; - children = ( - 7CED8EF92EF2D1F900457128 /* ChatListTableViewModel.swift */, - 7CED8EFA2EF2D1F900457128 /* ChatListTableViewController.swift */, - ); - path = ChatList; - sourceTree = ""; - }; - 7CED8F002EF2D1F900457128 /* ChatHistoryTableView */ = { + 7CED8EFB2EF2D1F900457128 /* LostItemChatList */ = { isa = PBXGroup; children = ( - 7CED8EFC2EF2D1F900457128 /* ChatHistoryTableView.swift */, - 7CED8EFD2EF2D1F900457128 /* ChatTextTableViewCell.swift */, - 7CED8EFE2EF2D1F900457128 /* ChatImageTableViewCell.swift */, - 7CED8EFF2EF2D1F900457128 /* ChatDateHeaderView.swift */, + 7CED8EF92EF2D1F900457128 /* LostItemChatListTableViewModel.swift */, + 7CED8EFA2EF2D1F900457128 /* LostItemChatListTableViewController.swift */, ); - path = ChatHistoryTableView; + path = LostItemChatList; sourceTree = ""; }; - 7CED8F042EF2D1F900457128 /* Chat */ = { + 7CED8F002EF2D1F900457128 /* LostItemChatHistoryTableView */ = { isa = PBXGroup; children = ( - 7CED8F002EF2D1F900457128 /* ChatHistoryTableView */, - 7CED8F012EF2D1F900457128 /* BlockCheckModalViewController.swift */, - 7CED8F022EF2D1F900457128 /* ChatViewModel.swift */, - 7CED8F032EF2D1F900457128 /* ChatViewController.swift */, + 7CED8EFC2EF2D1F900457128 /* LostItemChatHistoryTableView.swift */, + 7CED8EFD2EF2D1F900457128 /* LostItemChatTextTableViewCell.swift */, + 7CED8EFE2EF2D1F900457128 /* LostItemChatImageTableViewCell.swift */, + 7CED8EFF2EF2D1F900457128 /* LostItemChatDateHeaderView.swift */, ); - path = Chat; + path = LostItemChatHistoryTableView; sourceTree = ""; }; - 7CED8F052EF2D1F900457128 /* Chat */ = { + 7CED8F042EF2D1F900457128 /* LostItemChat */ = { isa = PBXGroup; children = ( - 7CED8EFB2EF2D1F900457128 /* ChatList */, - 7CED8F042EF2D1F900457128 /* Chat */, + 7CED8F002EF2D1F900457128 /* LostItemChatHistoryTableView */, + 7CED8F012EF2D1F900457128 /* LostItemBlockCheckModalViewController.swift */, + 7CED8F022EF2D1F900457128 /* LostItemChatViewModel.swift */, + 7CED8F032EF2D1F900457128 /* LostItemChatViewController.swift */, ); - path = Chat; + path = LostItemChat; sourceTree = ""; }; 7CED8F092EF2D1F900457128 /* Settings */ = { @@ -3465,7 +3577,6 @@ 7CED8F252EF2D1F900457128 /* Login */ = { isa = PBXGroup; children = ( - 7CED8F222EF2D1F900457128 /* ModifyUserModalViewController.swift */, 7CED8F232EF2D1F900457128 /* LoginViewModel.swift */, 7CED8F242EF2D1F900457128 /* LoginViewController.swift */, ); @@ -3477,9 +3588,9 @@ children = ( 7CED8F262EF2D1F900457128 /* AgreementFormViewController.swift */, 7CED8F272EF2D1F900457128 /* CertificationFormViewController.swift */, + 7CED8F2B2EF2D1F900457128 /* SelectTypeFormViewController.swift */, 7CED8F282EF2D1F900457128 /* EnterFormViewController.swift */, 7CED8F292EF2D1F900457128 /* RegisterCompletionViewController.swift */, - 7CED8F2B2EF2D1F900457128 /* SelectTypeFormViewController.swift */, ); path = ViewControllers; sourceTree = ""; @@ -3682,6 +3793,7 @@ children = ( 7CED8F662EF2D1F900457128 /* HotNoticeArticlesTableView */, 7CED8F692EF2D1F900457128 /* NoticeAttachmentsTableView */, + 7C7BD724302B3E25003C5A15 /* NoticeAISummaryView.swift */, ); path = SubViews; sourceTree = ""; @@ -3934,7 +4046,6 @@ 7CED90052EF2D1F900457128 /* ReviewListCollectionView.swift */, 7CED90062EF2D1F900457128 /* ReviewListCollectionViewCell.swift */, 7CED90072EF2D1F900457128 /* ReviewListHeaderView.swift */, - 7CED90082EF2D1F900457128 /* ImageDropDownCell.swift */, ); path = ReviewListCollectionView; sourceTree = ""; @@ -3947,8 +4058,6 @@ 7CED90092EF2D1F900457128 /* ReviewListCollectionView */, 7CED900A2EF2D1F900457128 /* ScoreView.swift */, 7CED900B2EF2D1F900457128 /* NonReviewListView.swift */, - 7CED900C2EF2D1F900457128 /* ReviewLoginModalViewController.swift */, - 7CED900D2EF2D1F900457128 /* DeleteReviewModalViewController.swift */, ); path = SubViews; sourceTree = ""; @@ -3998,7 +4107,6 @@ 7CED901A2EF2D1F900457128 /* SubViews */, 7CED901B2EF2D1F900457128 /* ShopReviewViewModel.swift */, 7CED901C2EF2D1F900457128 /* ShopReviewViewController.swift */, - 7CED901D2EF2D1F900457128 /* BackButtonPopUpViewController.swift */, ); path = ShopReview; sourceTree = ""; @@ -4284,10 +4392,10 @@ isa = PBXGroup; children = ( 7C86751030062FF3003CB942 /* Core */, + D19A00013000000000000007 /* Shared */, 7C457B2A2FCE1BA80011D338 /* Home */, 7C016541300A9FF80013DD7B /* Department */, 7CED8EF42EF2D1F900457128 /* LostItem */, - 7CED8F052EF2D1F900457128 /* Chat */, 7CED8F202EF2D1F900457128 /* Setting */, 7CED8F3B2EF2D1F900457128 /* Login */, 7CED8F722EF2D1F900457128 /* Notice */, @@ -4394,6 +4502,7 @@ 833D9C422C7061EC00982145 /* NoticeArticlesInfo.swift */, 833D9C332C70418100982145 /* NoticeListPages.swift */, 833D9C5F2C7457EE00982145 /* NoticeDataInfo.swift */, + 7C7BD722302B3D8F003C5A15 /* NoticeAISummary.swift */, 831ABDC32CA29A120099B70C /* NoticeKeywordsFetchResult.swift */, 838F114F2CA5330900BF3B25 /* AddNoticeKeywordType.swift */, ); @@ -4404,6 +4513,7 @@ isa = PBXGroup; children = ( 833D9C212C6B4F6E00982145 /* NoticeListDto.swift */, + 7C7BD726302D8002003C5A15 /* NoticeAISummaryDto.swift */, 833D9C272C6B58ED00982145 /* NoticeKeywordDto.swift */, ); path = NoticeList; @@ -4512,9 +4622,9 @@ D27DCD682BA5BADC0081FD36 /* Info.plist */, D2D380E02BA6AC50009AD5A9 /* koin.entitlements */, D27DD0202BA608310081FD36 /* Koin */, - A001E2D12845091F00D6C310 /* koinUITests */, 839D71172BEB1570001BC7F7 /* NotificationService */, 7C7F56B52FFB9F4A00847151 /* Common */, + EC69228F302700E800EE26ED /* koinUnitTests */, A001E2B52845091D00D6C310 /* Products */, ); sourceTree = ""; @@ -4523,20 +4633,41 @@ isa = PBXGroup; children = ( A001E2B42845091D00D6C310 /* koin.app */, - A001E2C42845091F00D6C310 /* koinTests.xctest */, - A001E2CE2845091F00D6C310 /* koinUITests.xctest */, 839D71162BEB1570001BC7F7 /* NotificationService.appex */, + EC692285302700A400EE26ED /* koinUnitTests.xctest */, ); name = Products; sourceTree = ""; }; - A001E2D12845091F00D6C310 /* koinUITests */ = { + AA30000000000000000001 /* Support */ = { isa = PBXGroup; children = ( - A001E2D22845091F00D6C310 /* koinUITests.swift */, - A001E2D42845091F00D6C310 /* koinUITestsLaunchTests.swift */, + AA10000000000000000001 /* PublisherTestSupport.swift */, + AA10000000000000000002 /* DiningFixtures.swift */, + AA10000000000000000007 /* DiningLoggingTestSupport.swift */, ); - path = koinUITests; + path = Support; + sourceTree = ""; + }; + AA30000000000000000002 /* Doubles */ = { + isa = PBXGroup; + children = ( + AA10000000000000000003 /* SpyDiningRepository.swift */, + AA10000000000000000008 /* DiningViewModelStubs.swift */, + ); + path = Doubles; + sourceTree = ""; + }; + AA30000000000000000003 /* Dining */ = { + isa = PBXGroup; + children = ( + AA10000000000000000004 /* DateProviderTests.swift */, + AA10000000000000000005 /* FetchDiningListUseCaseTests.swift */, + AA10000000000000000006 /* ShareMenuListUseCaseTests.swift */, + AA10000000000000000009 /* DiningLoggingTests.swift */, + AA10000000000000000010 /* DiningTypeTests.swift */, + ); + path = Dining; sourceTree = ""; }; B46B8D242E770A3100A8E797 /* Fonts */ = { @@ -4550,17 +4681,6 @@ path = Fonts; sourceTree = ""; }; - B608F0E52EB5DC2C0006F355 /* OrderHistoryUIComponents */ = { - isa = PBXGroup; - children = ( - B608F0E22EB5DC2C0006F355 /* EmptyStateView.swift */, - B608F0E32EB5DC2C0006F355 /* FilteringButton.swift */, - B608F0E42EB5DC2C0006F355 /* OrderHistoryCustomSearchBar.swift */, - D8F5C5492E6EF76800FB6708 /* OrderFloatingButton.swift */, - ); - path = OrderHistoryUIComponents; - sourceTree = ""; - }; B60D25452ED72017000E57B7 /* RadioButton */ = { isa = PBXGroup; children = ( @@ -4613,6 +4733,69 @@ path = Configuration; sourceTree = ""; }; + D19A00013000000000000007 /* Shared */ = { + isa = PBXGroup; + children = ( + 7C8B37A73040286C00A7EB5A /* FilterBottomSheet */, + D19A1001300000000000000C /* Chat */, + D19A00013000000000000008 /* Notification */, + ); + path = Shared; + sourceTree = ""; + }; + D19A00013000000000000008 /* Notification */ = { + isa = PBXGroup; + children = ( + D19A00013000000000000009 /* Models */, + 7C8A94202FD0A95100DEA6F5 /* Views */, + ); + path = Notification; + sourceTree = ""; + }; + D19A00013000000000000009 /* Models */ = { + isa = PBXGroup; + children = ( + D19A00013000000000000001 /* NotificationRowModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + D19A0001300000000000000A /* Support */ = { + isa = PBXGroup; + children = ( + D19A00013000000000000005 /* NotificationRowModel+NotificationHistoryItem.swift */, + ); + path = Support; + sourceTree = ""; + }; + D19A1001300000000000000C /* Chat */ = { + isa = PBXGroup; + children = ( + D19A1001300000000000000D /* Models */, + D19A1001300000000000000E /* Views */, + ); + path = Chat; + sourceTree = ""; + }; + D19A1001300000000000000D /* Models */ = { + isa = PBXGroup; + children = ( + D19A10013000000000000001 /* ChatListModel.swift */, + D19A10013000000000000003 /* ChatMessageRowModel.swift */, + ); + path = Models; + sourceTree = ""; + }; + D19A1001300000000000000E /* Views */ = { + isa = PBXGroup; + children = ( + D19A10013000000000000005 /* ChatListView.swift */, + D19A10013000000000000007 /* ChatInputView.swift */, + 7CC42B922F5DCE9800940CE1 /* ChatTableView */, + ); + path = Views; + sourceTree = ""; + }; D2009F7D2C1784C000211D1B /* MockService */ = { isa = PBXGroup; children = ( @@ -4827,7 +5010,6 @@ 7C8A943E2FD424E400DEA6F5 /* Home */, 7CA60D712F66217E007C08C1 /* CallVan */, 7C8ADD362F20C0A500F85BDE /* LostItem */, - D2D4625E2D63A78000C60864 /* Chat */, 833D9C202C6B4F4C00982145 /* NoticeList */, D29F21EE2C4F611F00994554 /* Noti */, D29F21ED2C4F610400994554 /* User */, @@ -5107,8 +5289,7 @@ 7CED8ED32EF2D1F900457128 /* CancelableImageView.swift */, 7CC42B062F56A7E900940CE1 /* ZoomedImageViewController */, 7CA0D6172EC744E700E9A282 /* ZoomedImageViewControllerB */, - 835C991D2C7F1256002E02D3 /* ModalViewController.swift */, - 7C372F382F1DCFF800149729 /* ModalViewControllerB.swift */, + 7C333E7A302E280C009D2B89 /* KoinModalViewController */, D2FB23442BFB6D090098BA2B /* CustomNavigationController.swift */, D20AB96E2C574291006BC684 /* BottomSheetViewController.swift */, 7CC42B822F5D307C00940CE1 /* BottomSheetViewControllerB.swift */, @@ -5117,12 +5298,11 @@ 83B7457C2C9B2EBB005868BD /* IndicatorView.swift */, 83EBB8792D0FDFD700346018 /* KoinPickerView.swift */, 7CC42B732F5AD39C00940CE1 /* KoinPickerDropDownView */, + FE01000000000000000031 /* KoinDropdown */, D289873D2D21B30D00AA7285 /* DebouncedButton.swift */, D89F8FD82DF0A8A4000265DC /* DefaultTextField.swift */, D89F8FDA2DF12847000265DC /* StatefulButton.swift */, - D891F25E2E45DC9D006D1F41 /* TrackPaddedSlider.swift */, 7C61D3502F22B344005FAC3A /* ExtendedTouchAreaView.swift */, - B608F0E52EB5DC2C0006F355 /* OrderHistoryUIComponents */, 7C65FDCA2FE69A00007831CE /* AutoScrollableInfiniteCarouselCollectionView */, ); path = View; @@ -5133,7 +5313,6 @@ children = ( 7C640405300F3EA000A9589F /* Department */, 7CA60D6E2F661B29007C08C1 /* CallVan */, - 7CCB1E4C2F29DB2500472669 /* Chat */, 7C372ED72F1B7F3100149729 /* LostItem */, 833D9C3A2C705E3F00982145 /* NoticeList */, 7C04A47E2FB3380700214992 /* Noti */, @@ -5157,36 +5336,14 @@ path = UserInputConfirmer; sourceTree = ""; }; - D2D4625E2D63A78000C60864 /* Chat */ = { + D2D4625E2D63A78000C60864 /* LostItemChat */ = { isa = PBXGroup; children = ( - D2D4625F2D63A81500C60864 /* ChatRoomDto.swift */, - D2D462722D63CACC00C60864 /* ChatDetailDto.swift */, - D2D4627B2D6482AA00C60864 /* CreateChatRoomResponse.swift */, + D2D4625F2D63A81500C60864 /* LostItemChatRoomDto.swift */, + D2D462722D63CACC00C60864 /* LostItemChatDetailDto.swift */, + D2D4627B2D6482AA00C60864 /* LostItemCreateChatRoomResponse.swift */, ); - path = Chat; - sourceTree = ""; - }; - D2D462642D63B97600C60864 /* Chat */ = { - isa = PBXGroup; - children = ( - D2D462652D63B98F00C60864 /* FetchChatRoomUseCase.swift */, - D2D462742D63CB3E00C60864 /* FetchChatDetailUseCase.swift */, - D2D462792D63D4D400C60864 /* BlockUserUseCase.swift */, - D2D4627D2D64835A00C60864 /* CreateChatRoomUseCase.swift */, - 7CCB1E4A2F29D87C00472669 /* PostChatDetailUseCase.swift */, - ); - path = Chat; - sourceTree = ""; - }; - D2D4626F2D63C58900C60864 /* Chat */ = { - isa = PBXGroup; - children = ( - D2D462702D63C5ED00C60864 /* ChatRoomItem.swift */, - D2B2193A2D66626900EAF5B1 /* ChatHistoryData.swift */, - D2B2193E2D66DDF900EAF5B1 /* ChatDateInfo.swift */, - ); - path = Chat; + path = LostItemChat; sourceTree = ""; }; D2D7C0D12C28293200C85A85 /* Land */ = { @@ -5225,7 +5382,6 @@ 7C8A00372FD400000011D338 /* Home */, 7CC42B452F5993DF00940CE1 /* CallVan */, 7C8ADD412F20C3CE00F85BDE /* LostItem */, - D2D462642D63B97600C60864 /* Chat */, 833D9C3F2C705F0E00982145 /* NoticeList */, D208A7C72CABB467007040E7 /* Core */, D20AB9502C5534DD006BC684 /* Noti */, @@ -5245,11 +5401,10 @@ D2FB23492BFD451F0098BA2B /* Model */ = { isa = PBXGroup; children = ( - 7C8A00362FD400000011D338 /* Home */, 7C7CCE6E2F866E8900E3A54B /* Core */, + 7C8A00362FD400000011D338 /* Home */, 7CA63A1F2F1AF73500226F86 /* LostItem */, D815DD972E08216B004A72BE /* Order */, - D2D4626F2D63C58900C60864 /* Chat */, D215726D2CEE28C30061E725 /* Timetable */, 833D9C102C6B269000982145 /* NoticeList */, D29F21EF2C4FD22900994554 /* Land */, @@ -5275,7 +5430,6 @@ D2D7C0CF2C28285600C85A85 /* LandRepository.swift */, D20AB94D2C553427006BC684 /* NotiRepository.swift */, 83DD0D7C2C4F91AD00278ABD /* BusRepository.swift */, - D2D462612D63B90100C60864 /* ChatRepository.swift */, 833D9C382C7058C200982145 /* NoticeListRepository.swift */, D208A79F2CA10F1A007040E7 /* AbTestRepository.swift */, D208A7C52CABB426007040E7 /* CoreRepository.swift */, @@ -5302,7 +5456,6 @@ 833D9C482C7068CB00982145 /* DefaultNoticeListRepository.swift */, D208A7D22CAD7436007040E7 /* DefaultAbTestRepository.swift */, D208A7CE2CABB85B007040E7 /* DefaultCoreRepository.swift */, - D2D4626D2D63BD1400C60864 /* DefaultChatRepository.swift */, 7CA60D642F6619B7007C08C1 /* DefaultCallVanRepository.swift */, 7CA686792FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift */, 7C640401300F39BB00A9589F /* DefaultDepartmentRepository.swift */, @@ -5365,7 +5518,6 @@ 833D9C462C7067AE00982145 /* NoticeListAPI.swift */, D208A7AD2CA117AA007040E7 /* AbTestAPI.swift */, D208A7CC2CABB7A3007040E7 /* CoreAPI.swift */, - D2D4626B2D63BAC600C60864 /* ChatAPI.swift */, 7C8ADD342F20BF4600F85BDE /* LostItemAPI.swift */, 7CA60D6C2F661A6D007C08C1 /* CallVanAPI.swift */, 7C8A94432FD4279B00DEA6F5 /* HomeAPI.swift */, @@ -5386,7 +5538,6 @@ D2FB23722C04305A0098BA2B /* Service */ = { isa = PBXGroup; children = ( - D2D462692D63BA5200C60864 /* ChatService.swift */, D20AB9572C553621006BC684 /* NotiService.swift */, D2FB236D2C0410510098BA2B /* LogAnalyticsService.swift */, D29F21C22C4DE27C00994554 /* UserService.swift */, @@ -5448,6 +5599,27 @@ path = Dining; sourceTree = ""; }; + EC69228F302700E800EE26ED /* koinUnitTests */ = { + isa = PBXGroup; + children = ( + AA30000000000000000001 /* Support */, + AA30000000000000000002 /* Doubles */, + AA30000000000000000003 /* Dining */, + ); + path = koinUnitTests; + sourceTree = ""; + }; + FE01000000000000000031 /* KoinDropdown */ = { + isa = PBXGroup; + children = ( + 7C8B2FF0303D928300A7EB5A /* Helper */, + FE01000000000000000016 /* KoinDropdownConfiguration.swift */, + D0A71C4E2F1B8340009E2D71 /* KoinDropdownHost.swift */, + FE01000000000000000011 /* KoinDropdown.swift */, + ); + path = KoinDropdown; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -5488,41 +5660,25 @@ productReference = A001E2B42845091D00D6C310 /* koin.app */; productType = "com.apple.product-type.application"; }; - A001E2C32845091F00D6C310 /* koinTests */ = { + EC692284302700A400EE26ED /* koinUnitTests */ = { isa = PBXNativeTarget; - buildConfigurationList = A001E2DB2845091F00D6C310 /* Build configuration list for PBXNativeTarget "koinTests" */; + buildConfigurationList = EC69228D302700A400EE26ED /* Build configuration list for PBXNativeTarget "koinUnitTests" */; buildPhases = ( - A001E2C02845091F00D6C310 /* Sources */, - A001E2C12845091F00D6C310 /* Frameworks */, - A001E2C22845091F00D6C310 /* Resources */, + EC692281302700A400EE26ED /* Sources */, + EC692282302700A400EE26ED /* Frameworks */, + EC692283302700A400EE26ED /* Resources */, ); buildRules = ( ); dependencies = ( - A001E2C62845091F00D6C310 /* PBXTargetDependency */, + EC69228A302700A400EE26ED /* PBXTargetDependency */, ); - name = koinTests; - productName = koinTests; - productReference = A001E2C42845091F00D6C310 /* koinTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - A001E2CD2845091F00D6C310 /* koinUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = A001E2DE2845091F00D6C310 /* Build configuration list for PBXNativeTarget "koinUITests" */; - buildPhases = ( - A001E2CA2845091F00D6C310 /* Sources */, - A001E2CB2845091F00D6C310 /* Frameworks */, - A001E2CC2845091F00D6C310 /* Resources */, + name = koinUnitTests; + packageProductDependencies = ( ); - buildRules = ( - ); - dependencies = ( - A001E2D02845091F00D6C310 /* PBXTargetDependency */, - ); - name = koinUITests; - productName = koinUITests; - productReference = A001E2CE2845091F00D6C310 /* koinUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; + productName = koinUnitTests; + productReference = EC692285302700A400EE26ED /* koinUnitTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ @@ -5531,7 +5687,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1530; + LastSwiftUpdateCheck = 2640; LastUpgradeCheck = 1530; TargetAttributes = { 839D71152BEB1570001BC7F7 = { @@ -5540,12 +5696,8 @@ A001E2B32845091D00D6C310 = { CreatedOnToolsVersion = 13.3.1; }; - A001E2C32845091F00D6C310 = { - CreatedOnToolsVersion = 13.3.1; - TestTargetID = A001E2B32845091D00D6C310; - }; - A001E2CD2845091F00D6C310 = { - CreatedOnToolsVersion = 13.3.1; + EC692284302700A400EE26ED = { + CreatedOnToolsVersion = 26.4.1; TestTargetID = A001E2B32845091D00D6C310; }; }; @@ -5579,9 +5731,8 @@ projectRoot = ""; targets = ( A001E2B32845091D00D6C310 /* koin */, - A001E2C32845091F00D6C310 /* koinTests */, - A001E2CD2845091F00D6C310 /* koinUITests */, 839D71152BEB1570001BC7F7 /* NotificationService */, + EC692284302700A400EE26ED /* koinUnitTests */, ); }; /* End PBXProject section */ @@ -5616,21 +5767,10 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A001E2C22845091F00D6C310 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D80AD4E52E6C6BC30061334B /* waveLogo.json in Resources */, - D8F5C5552E6F144300FB6708 /* floatingLogo.json in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - A001E2CC2845091F00D6C310 /* Resources */ = { + EC692283302700A400EE26ED /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - D80AD4E62E6C6BC30061334B /* waveLogo.json in Resources */, - D8F5C5542E6F144300FB6708 /* floatingLogo.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5666,7 +5806,7 @@ 839D71192BEB1570001BC7F7 /* NotificationService.swift in Sources */, 7CA686902FFCF42E007937E9 /* NotificationHistoryError.swift in Sources */, 7C7F56B92FFBA0A700847151 /* AppPath.swift in Sources */, - 7C7F56B82FFBA06200847151 /* NotificationRecord.swift in Sources */, + 7C7F56B82FFBA06200847151 /* NotificationHistoryRecord.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5674,6 +5814,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FE01000000000000000021 /* KoinDropdown.swift in Sources */, + D0A71C4F2F1B8340009E2D71 /* KoinDropdownHost.swift in Sources */, + FE01000000000000000023 /* KoinDropdownAnimator.swift in Sources */, + FE01000000000000000026 /* KoinDropdownConfiguration.swift in Sources */, D208A7CD2CABB7A3007040E7 /* CoreAPI.swift in Sources */, 7CA6867B2FFCB758007937E9 /* DefaultNotificationHistoryRepository.swift in Sources */, 833D9C052C68F8BA00982145 /* ScreenActionType.swift in Sources */, @@ -5682,7 +5826,7 @@ D22C54582C68B033000826DA /* ReportReviewRequest.swift in Sources */, D20AD6F92E02A11D0067760A /* FindIdSmsResponse.swift in Sources */, D27DD0D22BA608310081FD36 /* AppDelegate.swift in Sources */, - D2D4627C2D6482AA00C60864 /* CreateChatRoomResponse.swift in Sources */, + D2D4627C2D6482AA00C60864 /* LostItemCreateChatRoomResponse.swift in Sources */, D29F21D12C4E164C00994554 /* DefaultTimetableRepository.swift in Sources */, D22C54562C68B033000826DA /* WriteReviewRequest.swift in Sources */, 7C17EEA82EBF4F25008BCA89 /* ShopSearch.swift in Sources */, @@ -5698,8 +5842,7 @@ D215725C2CEDD04C0061E725 /* LectureRequest.swift in Sources */, D29F21F62C4FD2C700994554 /* FetchLandDetailRequest.swift in Sources */, D208A7A02CA10F1A007040E7 /* AbTestRepository.swift in Sources */, - D8F5C54A2E6EF76800FB6708 /* OrderFloatingButton.swift in Sources */, - D2B2193B2D66626900EAF5B1 /* ChatHistoryData.swift in Sources */, + D2B2193B2D66626900EAF5B1 /* LostItemChatHistoryData.swift in Sources */, 83DD0DBB2C551E5100278ABD /* BusTimetableInfo.swift in Sources */, B47839052E70FAFE00D002E3 /* OrderShopSummary.swift in Sources */, D249CD3D2C159350000813F9 /* DateProvider.swift in Sources */, @@ -5759,19 +5902,19 @@ 7C8ADD352F20BF4600F85BDE /* LostItemAPI.swift in Sources */, 7C8A94422FD4278800DEA6F5 /* HomeService.swift in Sources */, 83DD0D762C4F6FDE00278ABD /* BusPlace.swift in Sources */, - D2B2193F2D66DDF900EAF5B1 /* ChatDateInfo.swift in Sources */, + D2B2193F2D66DDF900EAF5B1 /* LostItemChatDateInfo.swift in Sources */, D89F8FD92DF0A8A4000265DC /* DefaultTextField.swift in Sources */, 7CC42B392F58284100940CE1 /* CallVanNotificationTableView.swift in Sources */, 7CC42B332F58247D00940CE1 /* CallVanNotification.swift in Sources */, B46B8CC22E76CB7300A8E797 /* ShopSummaryDto.swift in Sources */, - D2D462662D63B98F00C60864 /* FetchChatRoomUseCase.swift in Sources */, + D2D462662D63B98F00C60864 /* LostItemFetchChatRoomUseCase.swift in Sources */, 7CA0D6232EC74ADE00E9A282 /* ZoomedImageCollectionViewCell.swift in Sources */, D2BD36962BAA027A00FAE609 /* TokenRefreshRequest.swift in Sources */, D2D7C0D72C28299200C85A85 /* FetchLandDetailUseCase.swift in Sources */, D874DB4D2DBF1A6C0098EED0 /* SendVerificationCodeUsecase.swift in Sources */, 7CC42B622F59D0EF00940CE1 /* CallVanPostPlaceView.swift in Sources */, D27DD0E42BA609F30081FD36 /* ShopDataDto.swift in Sources */, - 7CC42B9C2F5E834E00940CE1 /* CallVanChatRightCell.swift in Sources */, + 7CC42B9C2F5E834E00940CE1 /* ChatRightCell.swift in Sources */, D20AD6EB2E028F950067760A /* FindIdSmsRequest.swift in Sources */, D27DD0CB2BA608310081FD36 /* ShopCategoryDto.swift in Sources */, 7C6790B32FCC5B2A0045E163 /* TimetableColorAsset.swift in Sources */, @@ -5807,7 +5950,7 @@ D27DD0CC2BA608310081FD36 /* ShopsDto.swift in Sources */, 7C61D3632F242D8D005FAC3A /* AddLostItemHeaderView.swift in Sources */, D874DB492DBE31530098EED0 /* CheckDuplicatedPhoneNumberUseCase.swift in Sources */, - 7CC42B982F5E812F00940CE1 /* CallVanChatLeftCell.swift in Sources */, + 7CC42B982F5E812F00940CE1 /* ChatLeftCell.swift in Sources */, 83DD0DE62C57406600278ABD /* GetBusFiltersUseCase.swift in Sources */, D22C54732C6A2A73000826DA /* FetchMyReviewUseCase.swift in Sources */, D29F21D52C4E1A8300994554 /* RegisterUseCase.swift in Sources */, @@ -5833,6 +5976,7 @@ 835DC1F92C849ECD00488506 /* FetchRecommendedKeywordUseCase.swift in Sources */, 7CF77B4930027539003642C1 /* TimeTableHourView.swift in Sources */, D23944822C3C24A200048F45 /* ReviewsDto.swift in Sources */, + 7C8261973040344700C20F64 /* FilterGroupCollectionView.swift in Sources */, D20AD6FD2E02A49A0067760A /* FindIdSmsUseCase.swift in Sources */, B60D25462ED72017000E57B7 /* RadioButtonState.swift in Sources */, 7C4D5A5D2F0163BA00B40128 /* DIContainer+Bus.swift in Sources */, @@ -5845,15 +5989,12 @@ 7C01656B300ABE9E0013DD7B /* DepartmentFooterView.swift in Sources */, 7C05D0782FBC29ED00F4D825 /* ShopBenefitViewModel.swift in Sources */, D29F21F32C4FD23F00994554 /* LandItem.swift in Sources */, - 7C7F56B72FFB9FE000847151 /* NotificationRecord.swift in Sources */, + 7C7F56B72FFB9FE000847151 /* NotificationHistoryRecord.swift in Sources */, D20AB95A2C55369F006BC684 /* NotiAPI.swift in Sources */, 7C457AE72FCDE7240011D338 /* SwiftUIViewModelProtocol.swift in Sources */, 833D9C392C7058C300982145 /* NoticeListRepository.swift in Sources */, D27DD0E22BA609740081FD36 /* MenuDto.swift in Sources */, D27A5D9C2C6BE56500C4275F /* FetchShopListRequest.swift in Sources */, - B608F0E62EB5DC2D0006F355 /* OrderHistoryCustomSearchBar.swift in Sources */, - B608F0E72EB5DC2D0006F355 /* FilteringButton.swift in Sources */, - B608F0E82EB5DC2D0006F355 /* EmptyStateView.swift in Sources */, D29F21C32C4DE27C00994554 /* UserService.swift in Sources */, D837FB122E254BD8002BFB9F /* CustomSessionManager.swift in Sources */, D2DCB2922CE5D639005CABEE /* PostCallNotificationUseCase.swift in Sources */, @@ -5881,7 +6022,7 @@ D2FFA9702D5F4DC500EF8E56 /* CheckLoginUseCase.swift in Sources */, 833D9C452C70672C00982145 /* NoticeListService.swift in Sources */, 7CA60D652F6619B7007C08C1 /* DefaultCallVanRepository.swift in Sources */, - D2D462602D63A81500C60864 /* ChatRoomDto.swift in Sources */, + D2D462602D63A81500C60864 /* LostItemChatRoomDto.swift in Sources */, D20AD6ED2E028FA20067760A /* FindIdEmailRequest.swift in Sources */, 7C5583622F11779400D3A980 /* ShopInfoFooterView.swift in Sources */, 831ABDC62CA2A7C20099B70C /* FetchRecentSearchedWordUseCase.swift in Sources */, @@ -5951,7 +6092,7 @@ D21572602CEDD2C60061E725 /* ModifyLectureUseCase.swift in Sources */, 7CC42B532F59C5FA00940CE1 /* CallVanPostViewModel.swift in Sources */, D20AB9522C5534F7006BC684 /* FetchNotiListUseCase.swift in Sources */, - 7CC42B282F575E8100940CE1 /* CallVanListFilterViewController.swift in Sources */, + 7C8B37B0304028B400A7EB5A /* FilterGroupModel.swift in Sources */, D29F21CA2C4E151000994554 /* TimetableRepository.swift in Sources */, 7CED90BC2EF2D1F900457128 /* ShopSummaryTableViewHeaderView.swift in Sources */, 7CED90BD2EF2D1F900457128 /* ManageNoticeKeywordViewController.swift in Sources */, @@ -5963,11 +6104,11 @@ 7CED90C62EF2D1F900457128 /* TimetableCollectionViewCell.swift in Sources */, 7C016556300AA2E10013DD7B /* DepartmentCategory.swift in Sources */, 7CED90CA2EF2D1F900457128 /* LandOptionCollectionViewCell.swift in Sources */, - 7CED90CB2EF2D1F900457128 /* ChatListTableViewModel.swift in Sources */, + 7CED90CB2EF2D1F900457128 /* LostItemChatListTableViewModel.swift in Sources */, 7CED90CE2EF2D1F900457128 /* ForceModifyUserViewController.swift in Sources */, 7CED90CF2EF2D1F900457128 /* ChangeMyProfileViewModel.swift in Sources */, 7C0C18912F67FA58001781B3 /* ReportCallVanUserUseCase.swift in Sources */, - 7CED90D02EF2D1F900457128 /* ChatHistoryTableView.swift in Sources */, + 7CED90D02EF2D1F900457128 /* LostItemChatHistoryTableView.swift in Sources */, 7CF77B543003B276003642C1 /* HomeLogoView.swift in Sources */, 7C016558300AA3790013DD7B /* DepartmentTask.swift in Sources */, 7CED90D12EF2D1F900457128 /* NoticeSearchViewController.swift in Sources */, @@ -5977,10 +6118,12 @@ 7CED90D52EF2D1F900457128 /* SelectDeptModalViewController.swift in Sources */, 7CED90D72EF2D1F900457128 /* RecommendedKeywordCollectionView.swift in Sources */, 7CED90D92EF2D1F900457128 /* DiningViewController.swift in Sources */, - 7CCB1E4B2F29D87C00472669 /* PostChatDetailUseCase.swift in Sources */, + 7C7BD725302B3E25003C5A15 /* NoticeAISummaryView.swift in Sources */, + 7CCB1E4B2F29D87C00472669 /* LostItemPostChatDetailUseCase.swift in Sources */, 7CED90DA2EF2D1F900457128 /* RecentSearchTableView.swift in Sources */, + 7C7BD727302D8002003C5A15 /* NoticeAISummaryDto.swift in Sources */, 7C61D35F2F24000C005FAC3A /* LostItemDataButtonsView.swift in Sources */, - 7CED90DB2EF2D1F900457128 /* ChatImageTableViewCell.swift in Sources */, + 7CED90DB2EF2D1F900457128 /* LostItemChatImageTableViewCell.swift in Sources */, 7CED90DC2EF2D1F900457128 /* ShopSummaryDeliveryButton.swift in Sources */, 7CED90DD2EF2D1F900457128 /* AddDirectCollectionViewCell.swift in Sources */, 7CED90DF2EF2D1F900457128 /* LandDetailViewModel.swift in Sources */, @@ -5988,7 +6131,6 @@ 7CED90E22EF2D1F900457128 /* PointLabel.swift in Sources */, 7CED90E32EF2D1F900457128 /* FindPhoneIdViewController.swift in Sources */, 7CED90E42EF2D1F900457128 /* SubstituteTimetableModalViewController.swift in Sources */, - 7CED90E62EF2D1F900457128 /* DeleteReviewModalViewController.swift in Sources */, 7CED90E72EF2D1F900457128 /* ForceUpdateViewController.swift in Sources */, 7CED90E92EF2D1F900457128 /* DiningCollectionView.swift in Sources */, 7C86750E30061AA0003CB942 /* CheckHasUnreadNotificationHistoryUseCase.swift in Sources */, @@ -5998,9 +6140,9 @@ 7CED90F12EF2D1F900457128 /* ForceUpdateViewModel.swift in Sources */, 7CA60D6D2F661A6D007C08C1 /* CallVanAPI.swift in Sources */, 7CED90F22EF2D1F900457128 /* ShopSearchViewModel.swift in Sources */, - 7CED90F42EF2D1F900457128 /* ChatListTableViewController.swift in Sources */, + 7CED90F42EF2D1F900457128 /* LostItemChatListTableViewController.swift in Sources */, 7CED90F52EF2D1F900457128 /* DiningViewModel.swift in Sources */, - 7CED90F62EF2D1F900457128 /* ChatDateHeaderView.swift in Sources */, + 7CED90F62EF2D1F900457128 /* LostItemChatDateHeaderView.swift in Sources */, 7CED90F72EF2D1F900457128 /* ReviewImageCollectionViewCell.swift in Sources */, 7CED90FA2EF2D1F900457128 /* LandDetailViewController.swift in Sources */, 7CED90FB2EF2D1F900457128 /* TabBarCollectionViewCell.swift in Sources */, @@ -6017,10 +6159,10 @@ 7CB11C802FB32AF1004E0C80 /* UnsubscribeLostItemKeywordUseCase.swift in Sources */, 7CED91022EF2D1F900457128 /* ShopCollectionViewCell.swift in Sources */, 7CED91042EF2D1F900457128 /* TimetableCell.swift in Sources */, - 7CCB1E4E2F29DB3200472669 /* PostChatDetailRequest.swift in Sources */, + 7CCB1E4E2F29DB3200472669 /* LostItemPostChatDetailRequest.swift in Sources */, 7CED91052EF2D1F900457128 /* SelectTypeFormViewController.swift in Sources */, 7CED91072EF2D1F900457128 /* AddDirectHeaderView.swift in Sources */, - 7CED91082EF2D1F900457128 /* BlockCheckModalViewController.swift in Sources */, + 7CED91082EF2D1F900457128 /* LostItemBlockCheckModalViewController.swift in Sources */, 7CED91092EF2D1F900457128 /* FacilityInfoViewController.swift in Sources */, 7CED910A2EF2D1F900457128 /* LeftAlignedFlowLayout.swift in Sources */, 7CED910B2EF2D1F900457128 /* AddClassCollectionView.swift in Sources */, @@ -6030,7 +6172,7 @@ 7CED910F2EF2D1F900457128 /* ShopCollectionView.swift in Sources */, 7CED91122EF2D1F900457128 /* LectureView.swift in Sources */, 7CA60D7D2F665128007C08C1 /* DeleteNotificationUseCase.swift in Sources */, - 7CED91142EF2D1F900457128 /* ChatViewController.swift in Sources */, + 7CED91142EF2D1F900457128 /* LostItemChatViewController.swift in Sources */, 7CED91152EF2D1F900457128 /* LoginViewModel.swift in Sources */, 7C7F56B42FFB6A6400847151 /* NotificationPopUpViewController.swift in Sources */, 7CED91162EF2D1F900457128 /* ShopSortOptionSheetViewController.swift in Sources */, @@ -6069,7 +6211,6 @@ 7CED91372EF2D1F900457128 /* ReviewListHeaderView.swift in Sources */, 7CED91382EF2D1F900457128 /* ShopReviewViewModel.swift in Sources */, 7C61D3432F220E8C005FAC3A /* EditLostItemHeaderView.swift in Sources */, - 7C7CCE4A2F834D4B00E3A54B /* CallVanModalViewController.swift in Sources */, 7CED91392EF2D1F900457128 /* DiningNotiContentViewController.swift in Sources */, 7CED913A2EF2D1F900457128 /* CalendarCollectionViewCell.swift in Sources */, 7CED913B2EF2D1F900457128 /* AddLostItemFooterView.swift in Sources */, @@ -6149,14 +6290,14 @@ 7C016561300AAD810013DD7B /* DepartmentSearchView.swift in Sources */, 7C016563300AB60B0013DD7B /* DepartmentRow.swift in Sources */, 7C457B452FCE1BA80011D338 /* HomeView.swift in Sources */, + 7C333E9C303026B6009D2B89 /* KoinModalStyle.swift in Sources */, 7C457B492FCE1BA80011D338 /* ShopView.swift in Sources */, 7C8A00012FD000000011D338 /* HomeViewModel.swift in Sources */, 7CED917A2EF2D1F900457128 /* ShopSummaryTableView.swift in Sources */, 7C61D34B2F220EB3005FAC3A /* EditLostItemFoundPlaceView.swift in Sources */, - 7CC42B942F5DCEA600940CE1 /* CallVanChatTableView.swift in Sources */, + 7CC42B942F5DCEA600940CE1 /* ChatTableView.swift in Sources */, 7CED917D2EF2D1F900457128 /* ReviewImageUploadCollectionView.swift in Sources */, 7CED917F2EF2D1F900457128 /* NoticeListViewController.swift in Sources */, - 7CED91802EF2D1F900457128 /* ReviewLoginModalViewController.swift in Sources */, 7CED91822EF2D1F900457128 /* DeliveryTipsCollectionView.swift in Sources */, 7C58FE892F5F9E7600A2737B /* CallVanData.swift in Sources */, 7CED91832EF2D1F900457128 /* NoticeDataViewModel.swift in Sources */, @@ -6174,7 +6315,6 @@ 7CA60D792F664952007C08C1 /* PostAllNotificationsReadUseCase.swift in Sources */, 7CED918D2EF2D1F900457128 /* ScoreChartCollectionViewCell.swift in Sources */, 7CB11C7C2FB32817004E0C80 /* LostItemKeyword.swift in Sources */, - 7CED918F2EF2D1F900457128 /* BackButtonPopUpViewController.swift in Sources */, 7C58FE862F5F9CDD00A2737B /* CallVanDataViewController.swift in Sources */, 7C372F202F1CD90B00149729 /* LostItemDataRecentHeaderView.swift in Sources */, 7CED91902EF2D1F900457128 /* StateView.swift in Sources */, @@ -6189,7 +6329,6 @@ 7CED91962EF2D1F900457128 /* ScoreChartCollectionView.swift in Sources */, 7CED91982EF2D1F900457128 /* PolicyListTableView.swift in Sources */, 7CED91992EF2D1F900457128 /* ShopSummaryPhoneButton.swift in Sources */, - 7CED919B2EF2D1F900457128 /* UpdateModalViewController.swift in Sources */, 7C8A94352FD2C40400DEA6F5 /* FetchShopCountUseCase.swift in Sources */, 7CED919E2EF2D1F900457128 /* ChangePasswordViewController.swift in Sources */, 7CED919F2EF2D1F900457128 /* RecentSearchTableViewCell.swift in Sources */, @@ -6226,6 +6365,7 @@ 7C4D5A482F01638800B40128 /* BusTimetableDataViewModel.swift in Sources */, 7C4D5A492F01638800B40128 /* BusSearchDatePickerViewController.swift in Sources */, 7C640412300F435E00A9589F /* DepartmentCategoryDto.swift in Sources */, + 7C8261A03040C7E700C20F64 /* CallVanListRequest+.swift in Sources */, 7C4D5A4A2F01638800B40128 /* BusTimetableRouteCollectionView.swift in Sources */, 7CC42B442F5991AE00940CE1 /* CallVanListViewModel.swift in Sources */, 7C61D34D2F220EBA005FAC3A /* EditLostItemContentView.swift in Sources */, @@ -6256,6 +6396,7 @@ 7C4D5A5A2F01638800B40128 /* BusAreaSelectdViewController.swift in Sources */, 7C4D5A5B2F01638800B40128 /* ManyBusTimetableCollectionViewCell.swift in Sources */, 7CED91AA2EF2D1F900457128 /* DeleteLectureView.swift in Sources */, + 7C82619C30407FF300C20F64 /* FilterGroupView.swift in Sources */, 7CED91AB2EF2D1F900457128 /* NoticeListViewModel.swift in Sources */, 7CB11C152FB1BDA6004E0C80 /* FetchLostItemKeywordSuggestionUseCase.swift in Sources */, 7C7F43282F65F31500CC5860 /* CallVanReportRequest.swift in Sources */, @@ -6304,19 +6445,18 @@ 7CED91BB2EF2D1F900457128 /* EnterFormViewController.swift in Sources */, 7CF77B4130027314003642C1 /* ProfileUserInfoView.swift in Sources */, 7CED91BD2EF2D1F900457128 /* RecommendedSearchCollectionViewCell.swift in Sources */, - 7CED91C02EF2D1F900457128 /* ChatTextTableViewCell.swift in Sources */, + 7CED91C02EF2D1F900457128 /* LostItemChatTextTableViewCell.swift in Sources */, 7CED91C12EF2D1F900457128 /* ShopViewModel.swift in Sources */, 7CED91C22EF2D1F900457128 /* ReviewImageCollectionView.swift in Sources */, 7CED91C62EF2D1F900457128 /* HotArticlesNoticeTableViewCell.swift in Sources */, 7C016576300BD9610013DD7B /* FetchDepartmentByCategoryUseCase.swift in Sources */, 7CED91C92EF2D1F900457128 /* CategoryCollectionView.swift in Sources */, 7CED91CB2EF2D1F900457128 /* ShopSummaryImagesCollectionViewCell.swift in Sources */, - 7CED91CD2EF2D1F900457128 /* ChatViewModel.swift in Sources */, + 7CED91CD2EF2D1F900457128 /* LostItemChatViewModel.swift in Sources */, 7C640400300F395300A9589F /* DepartmentRepository.swift in Sources */, 7CED91CE2EF2D1F900457128 /* NotiViewModel.swift in Sources */, 7CED91CF2EF2D1F900457128 /* ManageNoticeKeywordViewModel.swift in Sources */, 7CED91D22EF2D1F900457128 /* LandImageCollectionViewCell.swift in Sources */, - 7CED91D52EF2D1F900457128 /* ImageDropDownCell.swift in Sources */, 7CED91D72EF2D1F900457128 /* ShopDetailViewModel.swift in Sources */, 7C01656F300B4A2D0013DD7B /* DepartmentView.swift in Sources */, 7C7F429B2F629B8800CC5860 /* UploadDomain.swift in Sources */, @@ -6325,7 +6465,7 @@ 7C6403FE300F36F200A9589F /* DepartmentAPI.swift in Sources */, 7CA60D632F66197C007C08C1 /* CallVanRepository.swift in Sources */, 7C0C188F2F67F8BA001781B3 /* CallVanDataDto.swift in Sources */, - 7CC42B962F5DCEB300940CE1 /* CallVanChatDateHeaderView.swift in Sources */, + 7CC42B962F5DCEB300940CE1 /* ChatDateHeaderView.swift in Sources */, 7CED91DD2EF2D1F900457128 /* ShopDetailTableViewNameCell.swift in Sources */, 7C8A943B2FD424A600DEA6F5 /* WeatherDto.swift in Sources */, 7CED91DF2EF2D1F900457128 /* NotiViewController.swift in Sources */, @@ -6334,7 +6474,6 @@ 7C61D3492F220EAC005FAC3A /* EditLostItemFoundDateView.swift in Sources */, 7CC42B202F56F3C400940CE1 /* CallVanState.swift in Sources */, 7C8ADD382F20C0B200F85BDE /* LostItemListDto.swift in Sources */, - 7CED91E52EF2D1F900457128 /* ModifyUserModalViewController.swift in Sources */, 7C640409300F3F7700A9589F /* FetchDepartmentRequestDto.swift in Sources */, 7CED91E62EF2D1F900457128 /* ShopSummaryInfoView.swift in Sources */, 7CED91E92EF2D1F900457128 /* ShopDetailTableViewDeliveryTipsCell.swift in Sources */, @@ -6358,6 +6497,7 @@ 7CED92002EF2D1F900457128 /* MyKeywordCollectionView.swift in Sources */, 7CED92022EF2D1F900457128 /* ShopSummaryMenuGroupCollectionViewCell.swift in Sources */, 7CED92042EF2D1F900457128 /* DeleteSemesterModalViewController.swift in Sources */, + 7C8B37AD3040289500A7EB5A /* FilterItemModel.swift in Sources */, 7C04A47F2FB3380700214992 /* NotiSubscribeDetailRequest.swift in Sources */, 7C04A4802FB3380700214992 /* NotiSubscribeRequest.swift in Sources */, 7C04A4812FB3380700214992 /* SendDeviceTokenRequest.swift in Sources */, @@ -6376,7 +6516,6 @@ 7CED92132EF2D1F900457128 /* SettingsViewController.swift in Sources */, 7CED92142EF2D1F900457128 /* LandCollectionViewCell.swift in Sources */, 7C0C18952F68A913001781B3 /* FetchCallVanChatUseCase.swift in Sources */, - 7C372F392F1DCFF800149729 /* ModalViewControllerB.swift in Sources */, 7CED92172EF2D1F900457128 /* DiningOperatingTimeFooterView.swift in Sources */, 7C372EE62F1C0CC400149729 /* LostItemListData.swift in Sources */, 7CED92192EF2D1F900457128 /* ReportLostItemViewModel.swift in Sources */, @@ -6385,11 +6524,12 @@ 7C01656D300B49F80013DD7B /* DepartmentHostingController.swift in Sources */, B47839072E70FF2B00D002E3 /* OrderShopMenusGroups.swift in Sources */, 833D9C342C70418100982145 /* NoticeListPages.swift in Sources */, + 7C82619930403C5E00C20F64 /* FilterGroupCollectionViewCell.swift in Sources */, 7C7F43262F65F2F900CC5860 /* CallVanPostRequest.swift in Sources */, D20AB9582C553621006BC684 /* NotiService.swift in Sources */, 7CF77B6530056EB9003642C1 /* NoticeKeywordSearchButton.swift in Sources */, D29F21DF2C4F2E3000994554 /* FetchShopDataRequest.swift in Sources */, - D2D4627A2D63D4D400C60864 /* BlockUserUseCase.swift in Sources */, + D2D4627A2D63D4D400C60864 /* LostItemBlockUserUseCase.swift in Sources */, D21572562CEDC1830061E725 /* ModifyFrameUseCase.swift in Sources */, D2FB23572BFD50120098BA2B /* DefaultShopRepository.swift in Sources */, 833D9C602C7457EE00982145 /* NoticeDataInfo.swift in Sources */, @@ -6398,8 +6538,9 @@ D2FB23602BFDABBB0098BA2B /* Router.swift in Sources */, D89F8FDB2DF12847000265DC /* StatefulButton.swift in Sources */, D208A7BB2CA169DF007040E7 /* FetchBeneficialShopUseCase.swift in Sources */, - D2D462712D63C5ED00C60864 /* ChatRoomItem.swift in Sources */, + D2D462712D63C5ED00C60864 /* LostItemChatRoomItem.swift in Sources */, 7C8ADD2D2F20B43F00F85BDE /* UpdateLostItemRequest.swift in Sources */, + 7C7BD723302B3D8F003C5A15 /* NoticeAISummary.swift in Sources */, D2B7EBCA2D37C81B00EE46B0 /* PostLostItemRequest.swift in Sources */, 0FCD163D2BA80EAA00C23A2F /* KeychainWorker.swift in Sources */, 8354BE3D2C79D709009D4D7A /* AddNotificationKeywordUseCase.swift in Sources */, @@ -6439,6 +6580,7 @@ B68D2CE52EA505AA00F3B479 /* ToastIntent.swift in Sources */, D2FB236E2C0410510098BA2B /* LogAnalyticsService.swift in Sources */, D22C54752C6A2A80000826DA /* PostReviewUseCase.swift in Sources */, + 7C333E9330301B3D009D2B89 /* KoinModalViewController.swift in Sources */, D20AD6EF2E029B250067760A /* CheckVerificationEmailRequest.swift in Sources */, D261BB982D79418600A67F06 /* UserDataManager.swift in Sources */, D20AB96F2C574291006BC684 /* BottomSheetViewController.swift in Sources */, @@ -6454,13 +6596,22 @@ 7C8BFCE12FD04B8000963679 /* HomeDiningItem.swift in Sources */, 7C8BFCE32FD04B8000963679 /* HomeHeader.swift in Sources */, 7CF77B4430027321003642C1 /* TimeTableView.swift in Sources */, - 7C8BFCE42FD04B8000963679 /* NotificationItem.swift in Sources */, + 7C8BFCE42FD04B8000963679 /* NotificationHistoryItem.swift in Sources */, + D19A00013000000000000002 /* NotificationRowModel.swift in Sources */, + D19A00013000000000000004 /* NotificationListView.swift in Sources */, + D19A00013000000000000006 /* NotificationRowModel+NotificationHistoryItem.swift in Sources */, + D19A10013000000000000002 /* ChatListModel.swift in Sources */, + D19A10013000000000000004 /* ChatMessageRowModel.swift in Sources */, + D19A10013000000000000006 /* ChatListView.swift in Sources */, + D19A10013000000000000008 /* ChatInputView.swift in Sources */, + D19A1001300000000000000A /* ChatListModel+CallVanChat.swift in Sources */, 7C8BFCE52FD04B8000963679 /* CategoryModels.swift in Sources */, 7C8BFCF12FD04B800963679 /* HomeCounts.swift in Sources */, D2908D262BB8D11A0008F908 /* SemesterDto.swift in Sources */, D2D422722BB2A7BE00A8AF04 /* Confirmable.swift in Sources */, 7CA60D672F6619F4007C08C1 /* CallVanService.swift in Sources */, 7CC42B8F2F5DCE7400940CE1 /* CallVanChatViewController.swift in Sources */, + 7C333E7C302E285B009D2B89 /* KoinModalAnimator.swift in Sources */, 7C58FEAB2F6020A900A2737B /* CallVanReportImagesCollectionView.swift in Sources */, D29F21FC2C508A9C00994554 /* CheckPasswordRequest.swift in Sources */, D85939772DC12D0900CE4CB2 /* CheckDuplicatedIdRequest.swift in Sources */, @@ -6490,7 +6641,8 @@ D2DA83912C181A1400A2F156 /* FetchShopDataUseCase.swift in Sources */, 7CD411EA2FEB92280079467B /* HomeTabBarItem.swift in Sources */, D208A7AC2CA112D0007040E7 /* AssignAbTestResponse.swift in Sources */, - D2D462732D63CACC00C60864 /* ChatDetailDto.swift in Sources */, + D2D462732D63CACC00C60864 /* LostItemChatDetailDto.swift in Sources */, + 7C333EA330303678009D2B89 /* ModalContentView.swift in Sources */, D20AD6FF2E02A4AB0067760A /* FindIdEmailUseCase.swift in Sources */, D215724E2CEDBEC60061E725 /* FrameDto.swift in Sources */, 83DD0D842C4FA7FF00278ABD /* DefaultBusRepository.swift in Sources */, @@ -6505,15 +6657,13 @@ 8380FDC32BC1CBB800120036 /* BusCourseDto.swift in Sources */, 83DD0D742C4F6FD000278ABD /* BusType.swift in Sources */, 7C372F242F1CDB4400149729 /* LostItemDataContentView.swift in Sources */, - D2D4626E2D63BD1400C60864 /* DefaultChatRepository.swift in Sources */, - D2D462752D63CB3E00C60864 /* FetchChatDetailUseCase.swift in Sources */, + D2D462752D63CB3E00C60864 /* LostItemFetchChatDetailUseCase.swift in Sources */, 83DD0DBF2C55258200278ABD /* FetchShuttleBusRoutesUseCase.swift in Sources */, D2078BF62BC4CCEB00A39861 /* EventDto.swift in Sources */, D20AD6F32E029F7F0067760A /* SendVerificationEmailUseCase.swift in Sources */, D2FB236C2C0409900098BA2B /* LogAnalyticsEventUseCase.swift in Sources */, B4B3EB262E6E7CAD00F8A23A /* OrderShopMenus.swift in Sources */, 7C17EEB02EBF708B008BCA89 /* ShopSearchDto.swift in Sources */, - D891F25F2E45DC9D006D1F41 /* TrackPaddedSlider.swift in Sources */, D21572522CEDC1720061E725 /* FetchFrameUseCase.swift in Sources */, D2908D282BB8D1580008F908 /* LectureDto.swift in Sources */, D2908D2C2BB9D4150008F908 /* TimetablesDto.swift in Sources */, @@ -6524,15 +6674,15 @@ 7CC42B3C2F58284D00940CE1 /* CallVanNotificationTableViewCell.swift in Sources */, D20AD6F52E029F8D0067760A /* CheckVerificationEmailUseCase.swift in Sources */, 7C64040F300F428200A9589F /* DepartmentService.swift in Sources */, + 7C333EA53030429B009D2B89 /* ModalButtonView.swift in Sources */, D22C54712C6A2A60000826DA /* FetchShopReviewUseCase.swift in Sources */, 7C6403D3300BE8A100A9589F /* DepartmentEmptyView.swift in Sources */, - 7C8A94222FD0A96400DEA6F5 /* NotificationEmptyBackgroundView.swift in Sources */, + 7C8A94222FD0A96400DEA6F5 /* NotificationEmptyView.swift in Sources */, 8354BE522C7A0566009D4D7A /* CoreDataService.swift in Sources */, D2D7C0D52C28298500C85A85 /* FetchLandListUseCase.swift in Sources */, 7C17EEB22EBF7175008BCA89 /* FetchSearchShopUseCase.swift in Sources */, D2B2193D2D66828300EAF5B1 /* WebSocketManager.swift in Sources */, 7C7CCE702F866E9400E3A54B /* AppPath.swift in Sources */, - D2D4626A2D63BA5200C60864 /* ChatService.swift in Sources */, 7C61D3472F220EA2005FAC3A /* EditLostItemCategoryView.swift in Sources */, 7CED92462EF3DC2800457128 /* OrderCategoryCollectionViewCell.swift in Sources */, 7C8A94402FD426B400DEA6F5 /* HomeRepository.swift in Sources */, @@ -6544,7 +6694,6 @@ 7C58FEAD2F6020AD00A2737B /* CallVanReportImagesCollectionViewCell.swift in Sources */, 7C016548300AA1520013DD7B /* DepartmentCategoryViewModel.swift in Sources */, D249CD282C12DCCC000813F9 /* DiningService.swift in Sources */, - 835C991E2C7F1256002E02D3 /* ModalViewController.swift in Sources */, D2DA83952C181A3B00A2F156 /* FetchShopEventListUseCase.swift in Sources */, D29F21F82C5085B900994554 /* RefreshTokenRequest.swift in Sources */, 7CF77B3A300254BE003642C1 /* ProfileHostingController.swift in Sources */, @@ -6568,8 +6717,7 @@ D85939752DC12C9D00CE4CB2 /* CheckDuplicatedIdUsecase.swift in Sources */, 7CF77B3C300254C8003642C1 /* ProfileView.swift in Sources */, 83BB83132CEC814800946623 /* FetchKeywordNoticePhraseUseCase.swift in Sources */, - D2D4627E2D64835B00C60864 /* CreateChatRoomUseCase.swift in Sources */, - D2D4626C2D63BAC600C60864 /* ChatAPI.swift in Sources */, + D2D4627E2D64835B00C60864 /* LostItemCreateChatRoomUseCase.swift in Sources */, D21572502CEDC1680061E725 /* CreateFrameUseCase.swift in Sources */, D2009F762C17270F00211D1B /* CalendarDate.swift in Sources */, D29F21CC2C4E157E00994554 /* FetchDeptListUseCase.swift in Sources */, @@ -6578,14 +6726,15 @@ D21572662CEDDDC60061E725 /* FetchMySemesterUseCase.swift in Sources */, B68D2CEB2EA50C1E00F3B479 /* UIViewController+Toast.swift in Sources */, D249CD2A2C12DD34000813F9 /* DiningAPI.swift in Sources */, - D2D462622D63B90100C60864 /* ChatRepository.swift in Sources */, 83E770FC2D05B6DD00EB3F84 /* FetchEmergencyNoticeUseCase.swift in Sources */, 833D9C122C6B26A900982145 /* NoticeListType.swift in Sources */, D2FB23452BFB6D090098BA2B /* CustomNavigationController.swift in Sources */, D21572812CEF062A0061E725 /* DeleteSemesterUseCase.swift in Sources */, 7C6790B52FCC5B460045E163 /* ImageAsset.swift in Sources */, + 7C333E7E302E28AA009D2B89 /* KoinModalPresentationController.swift in Sources */, D874DB4F2DBF1FEE0098EED0 /* SendVerificationCodeRequest.swift in Sources */, 7CA60D772F663F7B007C08C1 /* PostNotificationReadUseCase.swift in Sources */, + 7C8B37A93040287D00A7EB5A /* FilterBottomSheetView.swift in Sources */, 7C8A941E2FD0A94F00DEA6F5 /* NotificationViewModel.swift in Sources */, 7C8A941F2FD0A94F00DEA6F5 /* NotificationViewController.swift in Sources */, 7CC42B402F597B5B00940CE1 /* CallVanNotificationViewModel.swift in Sources */, @@ -6601,6 +6750,7 @@ D22C547B2C6A2A9D000826DA /* ReportReviewUseCase.swift in Sources */, 7C8ADD452F20C3FB00F85BDE /* FetchLostItemListUseCase.swift in Sources */, B68D2CE72EA505B000F3B479 /* ToastMessageView.swift in Sources */, + 7C333E9530301CB7009D2B89 /* KoinModalConfiguration.swift in Sources */, D8F5C54C2E6F012500FB6708 /* LottieAnimationManageable.swift in Sources */, 83E770FA2D05B5EC00EB3F84 /* BusNoticeDto.swift in Sources */, D2FB23742C0432CD0098BA2B /* GA4AnalyticsRepository.swift in Sources */, @@ -6624,17 +6774,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - A001E2C02845091F00D6C310 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - A001E2CA2845091F00D6C310 /* Sources */ = { + EC692281302700A400EE26ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AA20000000000000000001 /* PublisherTestSupport.swift in Sources */, + AA20000000000000000002 /* DiningFixtures.swift in Sources */, + AA20000000000000000003 /* SpyDiningRepository.swift in Sources */, + AA20000000000000000004 /* DateProviderTests.swift in Sources */, + AA20000000000000000005 /* FetchDiningListUseCaseTests.swift in Sources */, + AA20000000000000000006 /* ShareMenuListUseCaseTests.swift in Sources */, + AA20000000000000000007 /* DiningLoggingTestSupport.swift in Sources */, + AA20000000000000000008 /* DiningViewModelStubs.swift in Sources */, + AA20000000000000000009 /* DiningLoggingTests.swift in Sources */, + AA20000000000000000010 /* DiningTypeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -6646,15 +6799,10 @@ target = 839D71152BEB1570001BC7F7 /* NotificationService */; targetProxy = 839D711B2BEB1570001BC7F7 /* PBXContainerItemProxy */; }; - A001E2C62845091F00D6C310 /* PBXTargetDependency */ = { + EC69228A302700A400EE26ED /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A001E2B32845091D00D6C310 /* koin */; - targetProxy = A001E2C52845091F00D6C310 /* PBXContainerItemProxy */; - }; - A001E2D02845091F00D6C310 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = A001E2B32845091D00D6C310 /* koin */; - targetProxy = A001E2CF2845091F00D6C310 /* PBXContainerItemProxy */; + targetProxy = EC692289302700A400EE26ED /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -6851,7 +6999,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = koin/koin.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 440; + CURRENT_PROJECT_VERSION = 446; DEVELOPMENT_TEAM = K626UYGR25; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -6870,7 +7018,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koin.stage; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -6886,7 +7034,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = koin/koin.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 440; + CURRENT_PROJECT_VERSION = 446; DEVELOPMENT_TEAM = K626UYGR25; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -6905,7 +7053,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 5.0.0; + MARKETING_VERSION = 5.2.0; PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koin; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -6914,79 +7062,56 @@ }; name = Release; }; - A001E2DC2845091F00D6C310 /* Debug */ = { + EC69228B302700A400EE26ED /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = K626UYGR25; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; - IPHONEOS_DEPLOYMENT_TARGET = 15.4; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koinTests; + PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koinUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/koin.app/koin"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/koin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/koin"; }; name = Debug; }; - A001E2DD2845091F00D6C310 /* Release */ = { + EC69228C302700A400EE26ED /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = K626UYGR25; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; - IPHONEOS_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koinTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/koin.app/koin"; - }; - name = Release; - }; - A001E2DF2845091F00D6C310 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = K626UYGR25; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koinUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = koin; - }; - name = Debug; - }; - A001E2E02845091F00D6C310 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = K626UYGR25; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = "코인(stage)"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koinUITests; + PRODUCT_BUNDLE_IDENTIFIER = com.bcsdlab.koinUnitTests; PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_TARGET_NAME = koin; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/koin.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/koin"; }; name = Release; }; @@ -7020,20 +7145,11 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - A001E2DB2845091F00D6C310 /* Build configuration list for PBXNativeTarget "koinTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - A001E2DC2845091F00D6C310 /* Debug */, - A001E2DD2845091F00D6C310 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - A001E2DE2845091F00D6C310 /* Build configuration list for PBXNativeTarget "koinUITests" */ = { + EC69228D302700A400EE26ED /* Build configuration list for PBXNativeTarget "koinUnitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - A001E2DF2845091F00D6C310 /* Debug */, - A001E2E02845091F00D6C310 /* Release */, + EC69228B302700A400EE26ED /* Debug */, + EC69228C302700A400EE26ED /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; diff --git a/koin.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme b/koin.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme index 25a32353..02329507 100644 --- a/koin.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme +++ b/koin.xcodeproj/xcshareddata/xcschemes/NotificationService.xcscheme @@ -44,6 +44,19 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> + + + + + + + + + + + + + skipped = "NO" + parallelizable = "YES"> - - - - diff --git a/koinUITests/koinUITests.swift b/koinUITests/koinUITests.swift deleted file mode 100644 index 36c80c9c..00000000 --- a/koinUITests/koinUITests.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// koinUITests.swift -// koinUITests -// -// Created by 정태훈 on 2022/05/30. -// - -import XCTest - -class koinUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/koinUITests/koinUITestsLaunchTests.swift b/koinUITests/koinUITestsLaunchTests.swift deleted file mode 100644 index b99bf8c1..00000000 --- a/koinUITests/koinUITestsLaunchTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// koinUITestsLaunchTests.swift -// koinUITests -// -// Created by 정태훈 on 2022/05/30. -// - -import XCTest - -class koinUITestsLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/koinUnitTests/Dining/DateProviderTests.swift b/koinUnitTests/Dining/DateProviderTests.swift new file mode 100644 index 00000000..6c8a3989 --- /dev/null +++ b/koinUnitTests/Dining/DateProviderTests.swift @@ -0,0 +1,80 @@ +// +// DateProviderTests.swift +// koinUnitTests +// +// Created by 이은지 on 8/8/26. +// + +import Foundation +import Testing +@testable import koin + +@Suite("DateProvider - 시간대 판단 로직 경계값") +struct DateProviderTests { + + private let sut = DefaultDateProvider() + private let calendar = Calendar.current + + private func date(hour: Int, minute: Int) throws -> Date { + try #require(DiningFixture.date(hour: hour, minute: minute, calendar: calendar)) + } + + @Test("09:00 이전이면 breakfast를 반환한다") + func 아홉시_이전이면_breakfast를_반환한다() throws { + let input = try date(hour: 8, minute: 59) + + let result = sut.execute(date: input) + + #expect(result.diningType == .breakfast) + #expect(calendar.isDate(result.date, inSameDayAs: input)) + } + + @Test("09:00이면 lunch를 반환한다") + func 아홉시면_lunch를_반환한다() throws { + let input = try date(hour: 9, minute: 0) + + let result = sut.execute(date: input) + + #expect(result.diningType == .lunch) + #expect(calendar.isDate(result.date, inSameDayAs: input)) + } + + @Test("13:30이면 lunch를 반환한다") + func 열세시_삼십분이면_lunch를_반환한다() throws { + let input = try date(hour: 13, minute: 30) + + let result = sut.execute(date: input) + + #expect(result.diningType == .lunch) + } + + @Test("13:31이면 dinner를 반환한다") + func 열세시_삼십일분이면_dinner를_반환한다() throws { + let input = try date(hour: 13, minute: 31) + + let result = sut.execute(date: input) + + #expect(result.diningType == .dinner) + #expect(calendar.isDate(result.date, inSameDayAs: input)) + } + + @Test("18:30이면 dinner를 반환한다") + func 열여덟시_삼십분이면_dinner를_반환한다() throws { + let input = try date(hour: 18, minute: 30) + + let result = sut.execute(date: input) + + #expect(result.diningType == .dinner) + } + + @Test("18:30 이후면 다음 날 breakfast를 반환한다", arguments: [(18, 31), (23, 59)]) + func 열여덟시_삼십분_이후면_다음_날_breakfast를_반환한다(hour: Int, minute: Int) throws { + let input = try date(hour: hour, minute: minute) + let expectedNextDay = try #require(calendar.date(byAdding: .day, value: 1, to: input)) + + let result = sut.execute(date: input) + + #expect(result.diningType == .breakfast) + #expect(calendar.isDate(result.date, inSameDayAs: expectedNextDay)) + } +} diff --git a/koinUnitTests/Dining/DiningLoggingTests.swift b/koinUnitTests/Dining/DiningLoggingTests.swift new file mode 100644 index 00000000..1988eb5b --- /dev/null +++ b/koinUnitTests/Dining/DiningLoggingTests.swift @@ -0,0 +1,123 @@ +// +// DiningLoggingTests.swift +// koinUnitTests +// +// Created by 이은지 on 8/17/26. +// + +import Foundation +import Testing +import UIKit +@testable import koin + +@MainActor +@Suite("DiningViewController - 로깅") +struct DiningLoggingTests { + + @Test( + "메뉴 이미지를 탭하면 시간대와 장소를 조합해 로깅한다", + arguments: [(0, "A코너", "아침_A코너"), (1, "B코너", "점심_B코너"), (2, "C코너", "저녁_C코너")] + ) + func 메뉴_이미지를_탭하면_시간대와_장소를_조합해_로깅한다(segmentIndex: Int, place: String, expectedValue: String) { + let bed = DiningLoggingTestBed() + bed.selectSegment(segmentIndex) + + bed.tapMenuImage(place: place) + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("menu_image", "click", expectedValue)]) + } + + @Test("공유 버튼을 탭하면 menuShare 이벤트를 로깅한다") + func 공유_버튼을_탭하면_menuShare_이벤트를_로깅한다() { + let bed = DiningLoggingTestBed() + + bed.tapShareButton() + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("menu_share", "click", "공유하기")]) + } + + @Test("공유 버튼을 한 번 탭하면 공유와 로그가 각각 한 번씩 발행된다") + func 공유_버튼을_한_번_탭하면_공유와_로그가_각각_한_번씩_발행된다() { + let bed = DiningLoggingTestBed() + + bed.tapShareButton() + + #expect(bed.recorder.inputKinds == ["shareMenuList", "logEvent"]) + } + + @Test( + "리스트를 스크롤하면 현재 시간대로 menuTime을 로깅한다", + arguments: [(0, "아침"), (1, "점심"), (2, "저녁")] + ) + func 리스트를_스크롤하면_현재_시간대로_menuTime을_로깅한다(segmentIndex: Int, expectedValue: String) { + let bed = DiningLoggingTestBed() + bed.selectSegment(segmentIndex) + + bed.scrollDiningList() + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("menu_time", "scroll", expectedValue)]) + } + + @Test( + "당겨서 새로고침하면 현재 시간대로 menuTime을 로깅한다", + arguments: [(0, "아침"), (1, "점심"), (2, "저녁")] + ) + func 당겨서_새로고침하면_현재_시간대로_menuTime을_로깅한다(segmentIndex: Int, expectedValue: String) { + let bed = DiningLoggingTestBed() + bed.selectSegment(segmentIndex) + + bed.pullToRefresh() + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("menu_time", "click", expectedValue)]) + } + + @Test("학생식당 정보 버튼을 탭하면 cafeteriaInfo 이벤트를 로깅한다") + func 학생식당_정보_버튼을_탭하면_cafeteriaInfo_이벤트를_로깅한다() { + let bed = DiningLoggingTestBed() + + bed.tapCafeteriaInfoButton() + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("cafeteria_info", "click", "학생식당정보")]) + } + + @Test("세그먼트를 전환하면 변경된 시간대로 menuTime을 로깅한다") + func 세그먼트를_전환하면_변경된_시간대로_menuTime을_로깅한다() { + let bed = DiningLoggingTestBed() + bed.selectSegment(0) + + bed.tapSegment(1) + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("menu_time", "click", "점심")]) + } + + @Test("스와이프로 전환해도 동일하게 로깅한다") + func 스와이프로_전환해도_동일하게_로깅한다() { + let bed = DiningLoggingTestBed() + bed.selectSegment(1) + + bed.swipe(.left) + + #expect(bed.recorder.loggedEvents == [DiningLoggedEvent("menu_time", "click", "저녁")]) + } + + @Test("세그먼트가 선택되지 않으면 로깅하지 않는다") + func 세그먼트가_선택되지_않으면_로깅하지_않는다() { + let bed = DiningLoggingTestBed() + bed.selectSegment(-1) + + bed.tapMenuImage(place: "A코너") + bed.scrollDiningList() + + #expect(bed.recorder.loggedEvents.isEmpty) + } + + @Test("무시된 스와이프는 로깅하지 않는다") + func 무시된_스와이프는_로깅하지_않는다() { + let bed = DiningLoggingTestBed() + bed.selectSegment(0) + + bed.swipe(.right) + + #expect(bed.recorder.loggedEvents.isEmpty) + } +} diff --git a/koinUnitTests/Dining/DiningTypeTests.swift b/koinUnitTests/Dining/DiningTypeTests.swift new file mode 100644 index 00000000..a0f79a06 --- /dev/null +++ b/koinUnitTests/Dining/DiningTypeTests.swift @@ -0,0 +1,38 @@ +// +// DiningTypeTests.swift +// koinUnitTests +// +// Created by 이은지 on 8/17/26. +// + +import Foundation +import Testing +@testable import koin + +@Suite("DiningType - 세그먼트 인덱스 변환") +struct DiningTypeTests { + + @Test( + "세그먼트 인덱스를 시간대로 옮긴다", + arguments: [(0, DiningType.breakfast), (1, .lunch), (2, .dinner)] + ) + func 세그먼트_인덱스를_시간대로_옮긴다(index: Int, expected: DiningType) { + #expect(DiningType(segmentIndex: index) == expected) + } + + @Test( + "미선택이거나 범위 밖의 인덱스는 시간대를 특정하지 않는다", + arguments: [-1, 3, 100] + ) + func 미선택이거나_범위_밖의_인덱스는_시간대를_특정하지_않는다(index: Int) { + #expect(DiningType(segmentIndex: index) == nil) + } + + @Test( + "옮긴 시간대는 로깅에 쓰이는 한글 이름을 가진다", + arguments: [(0, "아침"), (1, "점심"), (2, "저녁")] + ) + func 옮긴_시간대는_로깅에_쓰이는_한글_이름을_가진다(index: Int, expectedName: String) { + #expect(DiningType(segmentIndex: index)?.name == expectedName) + } +} diff --git a/koinUnitTests/Dining/FetchDiningListUseCaseTests.swift b/koinUnitTests/Dining/FetchDiningListUseCaseTests.swift new file mode 100644 index 00000000..68dc7544 --- /dev/null +++ b/koinUnitTests/Dining/FetchDiningListUseCaseTests.swift @@ -0,0 +1,88 @@ +// +// FetchDiningListUseCaseTests.swift +// koinUnitTests +// +// Created by 이은지 on 8/8/26. +// + +import Foundation +import Testing +@testable import koin + +@Suite("FetchDiningListUseCase - segmentControl에 따른 데이터 필터링") +struct FetchDiningListUseCaseTests { + + private func makeSUT( + stubbedDiningList: [DiningDto] + ) -> (sut: DefaultFetchDiningListUseCase, spy: SpyDiningRepository) { + let spy = SpyDiningRepository() + spy.stubbedDiningList = stubbedDiningList + return (DefaultFetchDiningListUseCase(diningRepository: spy), spy) + } + + private func diningInfo( + type: DiningType, + date: Date = Date() + ) -> CurrentDiningTime { + CurrentDiningTime(date: date, diningType: type) + } + + @Test("요청한 시간대의 식단만 반환한다") + func 요청한_시간대의_식단만_반환한다() async throws { + let (sut, _) = makeSUT(stubbedDiningList: [ + DiningFixture.dto(id: 1, type: .breakfast, place: .cornerA), + DiningFixture.dto(id: 2, type: .lunch, place: .cornerB), + DiningFixture.dto(id: 3, type: .dinner, place: .cornerC), + DiningFixture.dto(id: 4, type: .lunch, place: .special) + ]) + + let result = try await sut.execute(diningInfo: diningInfo(type: .lunch)).firstValue() + + #expect(result.count == 2) + #expect(result.allSatisfy { $0.type == .lunch }) + #expect(result.map(\.id).sorted() == [2, 4]) + } + + @Test( + "미운영 메뉴는 제외한다", + arguments: [DiningType.breakfast, .lunch, .dinner] + ) + func 미운영_메뉴는_제외한다(requestedType: DiningType) async throws { + let (sut, _) = makeSUT(stubbedDiningList: [ + DiningFixture.dto(id: 1, type: requestedType, place: .cornerA, menu: ["미운영"]), + DiningFixture.dto(id: 2, type: requestedType, place: .cornerB, menu: ["김치찌개", "밥"]) + ]) + + let result = try await sut.execute(diningInfo: diningInfo(type: requestedType)).firstValue() + + #expect(result.count == 1) + #expect(result.first?.id == 2) + #expect(!result.contains { $0.menu.first == "미운영" }) + } + + @Test("장소 우선순위대로 정렬한다") + func 장소_우선순위대로_정렬한다() async throws { + let (sut, _) = makeSUT(stubbedDiningList: [ + DiningFixture.dto(id: 1, type: .lunch, place: .secondCampus), + DiningFixture.dto(id: 2, type: .lunch, place: .special), + DiningFixture.dto(id: 3, type: .lunch, place: .cornerC), + DiningFixture.dto(id: 4, type: .lunch, place: .cornerA), + DiningFixture.dto(id: 5, type: .lunch, place: .cornerB) + ]) + + let result = try await sut.execute(diningInfo: diningInfo(type: .lunch)).firstValue() + + #expect(result.map(\.place) == [.cornerA, .cornerB, .cornerC, .special, .secondCampus]) + } + + @Test("요청 날짜를 yyMMdd 형식으로 전달한다") + func 요청_날짜를_yyMMdd_형식으로_전달한다() async throws { + let (sut, spy) = makeSUT(stubbedDiningList: []) + let requestedDate = try #require(DiningFixture.date(year: 2026, month: 8, day: 3)) + + _ = try await sut.execute(diningInfo: diningInfo(type: .lunch, date: requestedDate)).firstValue() + + #expect(spy.fetchDiningListCallCount == 1) + #expect(spy.receivedFetchRequests.first?.date == "260803") + } +} diff --git a/koinUnitTests/Dining/ShareMenuListUseCaseTests.swift b/koinUnitTests/Dining/ShareMenuListUseCaseTests.swift new file mode 100644 index 00000000..528b5c06 --- /dev/null +++ b/koinUnitTests/Dining/ShareMenuListUseCaseTests.swift @@ -0,0 +1,82 @@ +// +// ShareMenuListUseCaseTests.swift +// koinUnitTests +// +// Created by 이은지 on 8/8/26. +// + +import Foundation +import Testing +@testable import koin + +@Suite("ShareMenuListUseCase - 카카오톡 식단 공유하기") +struct ShareMenuListUseCaseTests { + + @Test("DiningItem을 ShareDiningMenu로 변환하면 메뉴와 이미지를 유지한다") + func DiningItem을_ShareDiningMenu로_변환하면_메뉴와_이미지를_유지한다() { + let item = DiningFixture.item( + type: .lunch, + place: .cornerA, + menu: ["김치찌개", "밥"], + imageUrl: "url1" + ) + + let shareModel = item.toShareDiningItem() + + #expect(shareModel.menuList == ["김치찌개", "밥"]) + #expect(shareModel.imageUrl == "url1") + #expect(shareModel.type == .lunch) + #expect(shareModel.place == .cornerA) + } + + @Test("날짜를 yyMMdd 형식으로 변환한다") + func 날짜를_yyMMdd_형식으로_변환한다() { + let item = DiningFixture.item(date: "2026-08-03") + + let shareModel = item.toShareDiningItem() + + #expect(shareModel.date == "260803") + } + + @Test( + "날짜 변환에 실패하면 원본 문자열을 사용한다", + arguments: ["", "날짜없음", "2026-13-45"] + ) + func 날짜_변환에_실패하면_원본_문자열을_사용한다(invalidDate: String) { + let item = DiningFixture.item(date: invalidDate) + + let shareModel = item.toShareDiningItem() + + #expect(shareModel.date == invalidDate) + } + + @Test("구분자가 달라도 파싱에 성공하면 yyMMdd로 변환된다") + func 구분자가_달라도_파싱에_성공하면_yyMMdd로_변환된다() { + let item = DiningFixture.item(date: "2026/08/03") + + let shareModel = item.toShareDiningItem() + + #expect(shareModel.date == "260803") + } + + @Test("공유 모델을 레포지토리에 전달한다") + func 공유_모델을_레포지토리에_전달한다() throws { + let spy = SpyDiningRepository() + let sut = DefaultShareMenuListUseCase(diningRepository: spy) + let shareModel = DiningFixture.item( + date: "2026-08-03", + place: .cornerB, + menu: ["돈까스"], + imageUrl: "url2" + ).toShareDiningItem() + + sut.execute(shareModel: shareModel) + + #expect(spy.receivedShareModels.count == 1) + let received = try #require(spy.receivedShareModels.first) + #expect(received.menuList == shareModel.menuList) + #expect(received.imageUrl == shareModel.imageUrl) + #expect(received.date == shareModel.date) + #expect(received.place == shareModel.place) + } +} diff --git a/koinUnitTests/Doubles/DiningViewModelStubs.swift b/koinUnitTests/Doubles/DiningViewModelStubs.swift new file mode 100644 index 00000000..584cb57e --- /dev/null +++ b/koinUnitTests/Doubles/DiningViewModelStubs.swift @@ -0,0 +1,55 @@ +// +// DiningViewModelStubs.swift +// koinUnitTests +// +// Created by 이은지 on 8/17/26. +// + +import Alamofire +import Combine +import Foundation +@testable import koin + +final class StubFetchDiningListUseCase: FetchDiningListUseCase { + func execute(diningInfo: CurrentDiningTime) -> AnyPublisher<[DiningItem], ErrorResponse> { + Empty<[DiningItem], ErrorResponse>().eraseToAnyPublisher() + } +} + +final class StubDateProvider: DateProvider { + func execute(date: Date) -> CurrentDiningTime { + CurrentDiningTime(date: date, diningType: .breakfast) + } +} + +final class StubShareMenuListUseCase: ShareMenuListUseCase { + func execute(shareModel: ShareDiningMenu) {} +} + +final class StubChangeNotiUseCase: ChangeNotiUseCase { + func execute(method: Alamofire.HTTPMethod, type: SubscribeType) -> AnyPublisher { + Empty().eraseToAnyPublisher() + } +} + +final class StubChangeNotiDetailUseCase: ChangeNotiDetailUseCase { + func execute(method: Alamofire.HTTPMethod, detailType: DetailSubscribeType) -> AnyPublisher { + Empty().eraseToAnyPublisher() + } +} + +final class StubFetchNotiListUseCase: FetchNotiListUseCase { + func execute() -> AnyPublisher { + Empty().eraseToAnyPublisher() + } +} + +final class StubLogAnalyticsEventUseCase: LogAnalyticsEventUseCase { + func execute(label: EventLabelType, category: EventParameter.EventCategory, value: Any) {} + + func executeWithDuration(label: EventLabelType, category: EventParameter.EventCategory, value: Any, previousPage: String?, currentPage: String?, durationTime: String?) {} + + func logEvent(name: String, label: String, value: String, category: String) {} + + func executeWithSessionId(label: EventLabelType, category: EventParameter.EventCategory, value: Any, sessionId: String) {} +} diff --git a/koinUnitTests/Doubles/SpyDiningRepository.swift b/koinUnitTests/Doubles/SpyDiningRepository.swift new file mode 100644 index 00000000..fff72f6d --- /dev/null +++ b/koinUnitTests/Doubles/SpyDiningRepository.swift @@ -0,0 +1,37 @@ +// +// SpyDiningRepository.swift +// koinUnitTests +// +// Created by 이은지 on 8/8/26. +// + +import Combine +import Foundation +@testable import koin + +/// 호출 여부와 전달된 인자를 기록만 하고, 검증은 테스트 코드에 맡기는 테스트 더블. +final class SpyDiningRepository: DiningRepository { + + /// `fetchDiningList`가 돌려줄 응답. 테스트에서 시나리오별로 갈아끼운다. + var stubbedDiningList: [DiningDto] = [] + + private(set) var fetchDiningListCallCount = 0 + private(set) var receivedFetchRequests: [FetchDiningListRequest] = [] + private(set) var receivedShareModels: [ShareDiningMenu] = [] + + func fetchDiningList(requestModel: FetchDiningListRequest) -> AnyPublisher<[DiningDto], ErrorResponse> { + fetchDiningListCallCount += 1 + receivedFetchRequests.append(requestModel) + return Just(stubbedDiningList) + .setFailureType(to: ErrorResponse.self) + .eraseToAnyPublisher() + } + + func fetchCoopShopList() -> AnyPublisher { + Empty().eraseToAnyPublisher() + } + + func shareMenuList(shareModel: ShareDiningMenu) { + receivedShareModels.append(shareModel) + } +} diff --git a/koinUnitTests/Support/DiningFixtures.swift b/koinUnitTests/Support/DiningFixtures.swift new file mode 100644 index 00000000..b4bca54e --- /dev/null +++ b/koinUnitTests/Support/DiningFixtures.swift @@ -0,0 +1,81 @@ +// +// DiningFixture.swift +// koinUnitTests +// +// Created by 이은지 on 8/8/26. +// + +import Foundation +@testable import koin + +enum DiningFixture { + + static func dto( + id: Int = 1, + date: String = "2026-08-03", + type: DiningType = .lunch, + place: DiningPlace = .cornerA, + menu: [String]? = ["김치찌개", "밥"], + imageURL: String? = nil + ) -> DiningDto { + DiningDto( + id: id, + date: date, + type: type, + place: place, + priceCard: nil, + priceCash: nil, + kcal: nil, + menu: menu, + createdAt: "", + updatedAt: "", + soldoutAt: nil, + changedAt: nil, + imageURL: imageURL, + likes: 0, + isLiked: false + ) + } + + static func item( + id: Int = 1, + date: String = "2026-08-03", + type: DiningType = .lunch, + place: DiningPlace = .cornerA, + menu: [String] = ["김치찌개", "밥"], + imageUrl: String? = nil + ) -> DiningItem { + DiningItem( + id: id, + type: type, + place: place, + priceCard: nil, + priceCash: nil, + kcal: 0, + menu: menu, + soldoutAt: nil, + changedAt: nil, + imageUrl: imageUrl, + likes: 0, + isLiked: false, + date: date + ) + } + + static func date( + year: Int = 2026, + month: Int = 8, + day: Int = 3, + hour: Int = 0, + minute: Int = 0, + calendar: Calendar = .current + ) -> Date? { + var components = DateComponents() + components.year = year + components.month = month + components.day = day + components.hour = hour + components.minute = minute + return calendar.date(from: components) + } +} diff --git a/koinUnitTests/Support/DiningLoggingTestSupport.swift b/koinUnitTests/Support/DiningLoggingTestSupport.swift new file mode 100644 index 00000000..e1fa37c2 --- /dev/null +++ b/koinUnitTests/Support/DiningLoggingTestSupport.swift @@ -0,0 +1,122 @@ +// +// DiningLoggingTestSupport.swift +// koinUnitTests +// +// Created by 이은지 on 8/17/26. +// + +import Combine +import UIKit +@testable import koin + +struct DiningLoggedEvent: Equatable { + let label: String + let category: String + let value: String + + init( + _ label: String, + _ category: String, + _ value: String + ) { + self.label = label + self.category = category + self.value = value + } +} + +final class DiningInputRecorder { + + private(set) var inputs: [DiningViewModel.Input] = [] + private var cancellable: AnyCancellable? + + init(_ viewController: DiningViewController) { + cancellable = viewController.inputSubject.sink { [weak self] input in + self?.inputs.append(input) + } + } + + var loggedEvents: [DiningLoggedEvent] { + inputs.compactMap { input in + guard case let .logEvent(label, category, value) = input else { return nil } + return DiningLoggedEvent(label.rawValue, category.rawValue, "\(value)") + } + } + + var inputKinds: [String] { + inputs.map { input in + switch input { + case .updateDisplayDateTime: "updateDisplayDateTime" + case .shareMenuList: "shareMenuList" + case .determineInitDate: "determineInitDate" + case .changeNoti: "changeNoti" + case .fetchNotiList: "fetchNotiList" + case .logEvent: "logEvent" + case .logEventWithSessionId: "logEventWithSessionId" + } + } + } +} + +@MainActor +final class DiningLoggingTestBed { + + let sut: DiningViewController + let recorder: DiningInputRecorder + + init() { + sut = DiningViewController( + viewModel: DiningViewModel( + fetchDiningListUseCase: StubFetchDiningListUseCase(), + logAnalyticsEventUseCase: StubLogAnalyticsEventUseCase(), + dateProvder: StubDateProvider(), + shareMenuListUseCase: StubShareMenuListUseCase(), + changeNotiUseCase: StubChangeNotiUseCase(), + fetchNotiListUsecase: StubFetchNotiListUseCase(), + changeNotiDetailUseCase: StubChangeNotiDetailUseCase() + ) + ) + + sut.loadViewIfNeeded() + recorder = DiningInputRecorder(sut) + } + + func selectSegment(_ index: Int) { + sut.diningTypeSegmentControl.selectedSegmentIndex = index + } + + func tapSegment(_ index: Int) { + selectSegment(index) + sut.diningTypeSegmentControl.sendActions(for: .valueChanged) + } + + func swipe(_ direction: UISwipeGestureRecognizer.Direction) { + let gesture = UISwipeGestureRecognizer() + gesture.direction = direction + sut.handleSwipe(gesture) + } + + func pullToRefresh() { + sut.diningListCollectionView.refreshControl?.sendActions(for: .valueChanged) + } + + func tapMenuImage(place: String) { + sut.diningListCollectionView.imageTapPublisher.send((UIImage(), place)) + } + + func tapShareButton() { + sut.diningListCollectionView.shareButtonPublisher.send(DiningFixture.item().toShareDiningItem()) + } + + func scrollDiningList() { + sut.diningListCollectionView.logScrollPublisher.send(()) + } + + func tapCafeteriaInfoButton() { + guard let item = sut.navigationItem.rightBarButtonItem, + let action = item.action, + let target = item.target as? NSObject + else { return } + target.perform(action) + } +} diff --git a/koinUnitTests/Support/PublisherTestSupport.swift b/koinUnitTests/Support/PublisherTestSupport.swift new file mode 100644 index 00000000..8dceeb16 --- /dev/null +++ b/koinUnitTests/Support/PublisherTestSupport.swift @@ -0,0 +1,26 @@ +// +// PublisherTestError.swift +// koinUnitTests +// +// Created by 이은지 on 8/8/26. +// + +import Combine +import Foundation + +enum PublisherTestError: Error { + case finishedWithoutValue +} + +extension Publisher { + /// Combine 파이프라인이 방출하는 첫 번째 값을 async로 받아온다. + /// + /// 프로덕션 코드가 `AnyPublisher`를 반환하기 때문에, Swift Testing의 `async` 테스트에서 + /// `await`로 결과를 기다리기 위해 `Publisher.values`(AsyncSequence)를 경유한다. + func firstValue() async throws -> Output { + for try await value in values { + return value + } + throw PublisherTestError.finishedWithoutValue + } +}