diff --git a/WhereAreYou/WhereAreYou/Core/Date+Format.swift b/WhereAreYou/WhereAreYou/Core/Date+Format.swift index 8f53f41..c8ac0e2 100644 --- a/WhereAreYou/WhereAreYou/Core/Date+Format.swift +++ b/WhereAreYou/WhereAreYou/Core/Date+Format.swift @@ -17,6 +17,10 @@ extension Date { Self.koreanDateTimeFormatter.string(from: self) } + var monthDayTimeString: String { + Self.monthDayTimeFormatter.string(from: self) + } + var koreanShortDateTimeString: String { Self.koreanShortDateTimeFormatter.string(from: self) } @@ -39,6 +43,13 @@ extension Date { return formatter }() + private static let monthDayTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "MM월 dd일 HH:mm" + return formatter + }() + private static let koreanShortDateTimeFormatter: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "ko_KR") diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListCard.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListCard.swift new file mode 100644 index 0000000..f42646f --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListCard.swift @@ -0,0 +1,110 @@ +// +// AppointmentListCard.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import UIKit + +// MARK: - 약속 목록 / 지난 약속 화면의 약속 카드 +// - 제목 + 인원/장소/날짜 3줄 + 대화 열기·지도 열기 버튼으로 구성 +// - 짧게 탭했을 땐 카드 자체는 반응하지 않고 대화/지도 버튼만 반응 +// - 꾹 눌렀을 땐 카드 전체가 반응해 컨텍스트 메뉴(알림 토글/약속 나가기)를 표시 +// - 오른쪽 상단 액세서리는 외부에서 주입 + +final class AppointmentListCard: AppointmentCardBase { + + var onChatTap: (() -> Void)? + var onMapTap: (() -> Void)? + var contextMenuProvider: (() -> UIMenu)? + + private lazy var chatButton: UIButton = { + let button = UIButton.filled( + title: "대화 열기", + background: .blue2.withAlphaComponent(0.8), + tint: .white, + font: .caption1, + edgeInsets: NSDirectionalEdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) + ) + button.layer.cornerRadius = 8 + button.configuration?.image = UIImage(systemName: "text.bubble") + button.configuration?.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( + font: .preferredFont(forTextStyle: .caption1) + ) + button.configuration?.imagePlacement = .leading + button.configuration?.imagePadding = 6 + return button + }() + + private lazy var mapButton: UIButton = { + let button = UIButton.filled( + title: "지도 열기", + background: .blue2.withAlphaComponent(0.8), + tint: .white, + font: .caption1, + edgeInsets: NSDirectionalEdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) + ) + button.layer.cornerRadius = 8 + button.configuration?.image = UIImage(systemName: "map") + button.configuration?.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( + font: .preferredFont(forTextStyle: .caption1) + ) + button.configuration?.imagePlacement = .leading + button.configuration?.imagePadding = 6 + return button + }() + + init(item: AppointmentListItem, accessoryView: UIView?) { + super.init( + participantCount: item.participantCount, + placeText: item.location?.title ?? "미정", + dateText: item.date?.appointmentDateTimeText ?? "미정", + title: item.title, + accessoryView: accessoryView + ) + setUpButtons() + setUpContextMenu() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented — use init(item:accessoryView:)") + } + + private func setUpButtons() { + let buttonStack = UIStackView(arrangedSubviews: [chatButton, mapButton]) + buttonStack.axis = .horizontal + buttonStack.spacing = 8 + buttonStack.distribution = .fillEqually + buttonStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(buttonStack) + + NSLayoutConstraint.activate([ + buttonStack.topAnchor.constraint(equalTo: rowStack.bottomAnchor, constant: 10), + buttonStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + buttonStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), + buttonStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8) + ]) + + chatButton.addAction(UIAction { [weak self] _ in self?.onChatTap?() }, for: .touchUpInside) + mapButton.addAction(UIAction { [weak self] _ in self?.onMapTap?() }, for: .touchUpInside) + } + + private func setUpContextMenu() { + addInteraction(UIContextMenuInteraction(delegate: self)) + } + +} + +// MARK: - UIContextMenuInteractionDelegate + +extension AppointmentListCard: UIContextMenuInteractionDelegate { + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + configurationForMenuAtLocation location: CGPoint + ) -> UIContextMenuConfiguration? { + UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.contextMenuProvider?() }) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift new file mode 100644 index 0000000..ce8b34e --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift @@ -0,0 +1,248 @@ +// +// AppointmentListViewController.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import UIKit + +final class AppointmentListViewController: UIViewController { + + private static let cardSpacing: CGFloat = 16 + private static let cardLeadingInset: CGFloat = 40 + private static let sectionHeaderLeadingInset: CGFloat = 26 + private static let sectionHeaderTopSpacing: CGFloat = 18 + private static let sectionHeaderBottomSpacing: CGFloat = 10 + + private let viewModel = AppointmentListViewModel() + + private let logoImageView: UIImageView = { + let imageView = UIImageView(image: .logo) + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.text = "약속 목록" + label.font = .boldPreferredFont(forTextStyle: .title1) + label.textColor = .blue1 + return label + }() + + private let scrollView = UIScrollView() + + private lazy var contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = Self.cardSpacing + return stack + }() + + private let emptyLabel: UILabel = { + let label = UILabel() + label.text = "약속이 없습니다." + label.font = .preferredFont(forTextStyle: .body) + label.textColor = .secondaryLabel + label.textAlignment = .center + label.isHidden = true + return label + }() + + private let pastAppointmentButton: UIButton = { + let button = UIButton(type: .system) + button.backgroundColor = .white + button.tintColor = .black + button.layer.cornerRadius = 24 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.25 + button.layer.shadowRadius = 4 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + + let symbolConfiguration = UIImage.SymbolConfiguration( + font: .systemFont(ofSize: 19, weight: .semibold) + ) + button.setImage( + UIImage( + systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90", + withConfiguration: symbolConfiguration + ), + for: .normal + ) + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + setUpLayout() + setUpActions() + reloadCards() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + } + + private func setUpLayout() { + [logoImageView, titleLabel, scrollView, emptyLabel, pastAppointmentButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + + contentStack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentStack) + + NSLayoutConstraint.activate([ + logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + logoImageView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + logoImageView.widthAnchor.constraint(equalToConstant: 77), + logoImageView.heightAnchor.constraint(equalToConstant: 48), + + titleLabel.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 18), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + scrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8), + contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: Self.cardLeadingInset), + contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -Self.cardLeadingInset), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), + contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -(Self.cardLeadingInset * 2)), + + emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + pastAppointmentButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + pastAppointmentButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24), + pastAppointmentButton.widthAnchor.constraint(equalToConstant: 48), + pastAppointmentButton.heightAnchor.constraint(equalToConstant: 48) + ]) + } + + private func setUpActions() { + pastAppointmentButton.addAction(UIAction { [weak self] _ in + self?.presentPastAppointmentList() + }, for: .touchUpInside) + } + + private func reloadCards( + today: [AppointmentListItem]? = nil, + upcoming: [AppointmentListItem]? = nil + ) { + let today = today ?? viewModel.todayAppointments + let upcoming = upcoming ?? viewModel.upcomingAppointments + + contentStack.arrangedSubviews.forEach { + contentStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + let hasAnyAppointment = !today.isEmpty || !upcoming.isEmpty + + emptyLabel.isHidden = hasAnyAppointment + scrollView.isHidden = !hasAnyAppointment + + if !today.isEmpty { + addSectionHeaderIfNeeded(title: "오늘 약속", isNeeded: !upcoming.isEmpty) + today.forEach { addCard(for: $0) } + } + + if !upcoming.isEmpty { + addSectionHeaderIfNeeded(title: "예정된 약속", isNeeded: !today.isEmpty) + upcoming.forEach { addCard(for: $0) } + } + } + + private func addSectionHeaderIfNeeded(title: String, isNeeded: Bool) { + guard isNeeded else { return } + + let label = UILabel() + label.text = title + label.font = .preferredFont(forTextStyle: .callout) + label.textColor = .secondaryLabel + + let headerContainer = UIView() + label.translatesAutoresizingMaskIntoConstraints = false + headerContainer.addSubview(label) + + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: headerContainer.topAnchor), + label.bottomAnchor.constraint(equalTo: headerContainer.bottomAnchor), + label.leadingAnchor.constraint( + equalTo: headerContainer.leadingAnchor, + constant: Self.sectionHeaderLeadingInset - Self.cardLeadingInset + ), + label.trailingAnchor.constraint(lessThanOrEqualTo: headerContainer.trailingAnchor) + ]) + + if let previousView = contentStack.arrangedSubviews.last { + contentStack.setCustomSpacing(Self.sectionHeaderTopSpacing, after: previousView) + } + contentStack.addArrangedSubview(headerContainer) + contentStack.setCustomSpacing(Self.sectionHeaderBottomSpacing, after: headerContainer) + } + + private func addCard(for item: AppointmentListItem) { + let remainingTimeLabel: UILabel = { + let label = UILabel() + label.font = .boldPreferredFont(forTextStyle: .footnote) + label.textColor = .blue2 + label.text = item.date?.remainingTimeText + return label + }() + + let card = AppointmentListCard(item: item, accessoryView: remainingTimeLabel) + card.onChatTap = { + print("\(item.title) 대화 열기") + } + card.onMapTap = { + print("\(item.title) 지도 열기") + } + card.contextMenuProvider = { [weak self] in + self?.makeContextMenu(id: item.id) ?? UIMenu(children: []) + } + + contentStack.addArrangedSubview(card) + } + + private func makeContextMenu(id: String) -> UIMenu { + guard let item = viewModel.item(id: id) else { return UIMenu(children: []) } + + let notificationAction = UIAction( + title: viewModel.notificationMenuTitle(for: item), + image: UIImage(systemName: viewModel.notificationMenuIcon(for: item)) + ) { [weak self] _ in + self?.viewModel.toggleNotification(id: item.id) + } + + let leaveAction = UIAction( + title: "약속 나가기", + image: UIImage(systemName: "rectangle.portrait.and.arrow.right"), + attributes: .destructive + ) { [weak self] _ in + self?.presentLeaveConfirmAlert(id: item.id, title: item.title) + } + + return UIMenu(children: [notificationAction, leaveAction]) + } + + private func presentLeaveConfirmAlert(id: String, title: String) { + let alert = UIAlertController.leaveConfirmAlert(title: title) { [weak self] in + guard let self else { return } + let (today, upcoming) = viewModel.leave(id: id) + reloadCards(today: today, upcoming: upcoming) + } + present(alert, animated: true) + } + + private func presentPastAppointmentList() { + navigationController?.pushViewController(PastAppointmentListViewController(), animated: true) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewModel.swift new file mode 100644 index 0000000..fea8728 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewModel.swift @@ -0,0 +1,138 @@ +// +// AppointmentListViewModel.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import Foundation + +// MARK: - 약속 목록 화면(탭)의 데이터 +// - "오늘 약속"은 date가 오늘 날짜(같은 day)인 약속, "예정된 약속"은 그 외 미래 약속 +// - 오늘 약속이 없으면 todayAppointments가 빈 배열이 되고, 화면은 헤더 없이 예정된 약속만 표시 + +final class AppointmentListViewModel { + + private(set) var todayAppointments: [AppointmentListItem] = [] + private(set) var upcomingAppointments: [AppointmentListItem] = [] + + init() { + loadDummyData() + } + + func toggleNotification(id: String) { + if let index = todayAppointments.firstIndex(where: { $0.id == id }) { + todayAppointments[index] = toggledNotification(of: todayAppointments[index]) + return + } + if let index = upcomingAppointments.firstIndex(where: { $0.id == id }) { + upcomingAppointments[index] = toggledNotification(of: upcomingAppointments[index]) + } + } + + func item(id: String) -> AppointmentListItem? { + todayAppointments.first { $0.id == id } ?? upcomingAppointments.first { $0.id == id } + } + + func notificationMenuTitle(for item: AppointmentListItem) -> String { + item.isNotificationEnabled ? "알림 끄기" : "알림 켜기" + } + + func notificationMenuIcon(for item: AppointmentListItem) -> String { + item.isNotificationEnabled ? "bell.slash" : "bell" + } + + func leave(id: String) -> (today: [AppointmentListItem], upcoming: [AppointmentListItem]) { + todayAppointments.removeAll { $0.id == id } + upcomingAppointments.removeAll { $0.id == id } + return (todayAppointments, upcomingAppointments) + } + + private func toggledNotification(of item: AppointmentListItem) -> AppointmentListItem { + AppointmentListItem( + id: item.id, + title: item.title, + participantCount: item.participantCount, + location: item.location, + date: item.date, + isNotificationEnabled: !item.isNotificationEnabled + ) + } + + private func loadDummyData() { + let allAppointments = [ + AppointmentListItem( + id: "1", + title: "고등학교 친구들과 저녁", + participantCount: 5, + location: AppointmentLocation( + title: "고기굽는방앗간 이수역점", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0) + ), + date: Calendar.current.date(bySettingHour: 12, minute: 0, second: 0, of: Date()), + isNotificationEnabled: true + ), + AppointmentListItem( + id: "today-2", + title: "오늘 저녁 약속", + participantCount: 3, + location: AppointmentLocation( + title: "테스트 장소", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0) + ), + date: Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()), + isNotificationEnabled: true + ), + AppointmentListItem( + id: "today-3", + title: "곧 시작하는 약속", + participantCount: 4, + location: AppointmentLocation( + title: "테스트 장소", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0) + ), + date: Calendar.current.date(byAdding: .minute, value: 30, to: Date()), + isNotificationEnabled: true + ), + AppointmentListItem( + id: "2", + title: "고등학교 친구들과 저녁", + participantCount: 5, + location: AppointmentLocation( + title: "고기굽는방앗간 이수역점", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0) + ), + date: Calendar.current.date(byAdding: .day, value: 1, to: Date()), + isNotificationEnabled: true + ), + AppointmentListItem( + id: "3", + title: "고등학교 친구들과 저녁", + participantCount: 5, + location: AppointmentLocation( + title: "고기굽는방앗간 이수역점", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0) + ), + date: Calendar.current.date(byAdding: .day, value: 365, to: Date()), + isNotificationEnabled: false + ) + ] + + let (today, upcoming) = allAppointments.reduce(into: ([AppointmentListItem](), [AppointmentListItem]())) { result, item in + if let date = item.date, Calendar.current.isDateInToday(date) { + result.0.append(item) + } else { + result.1.append(item) + } + } + + todayAppointments = today.sorted { ($0.date ?? .distantFuture) < ($1.date ?? .distantFuture) } + upcomingAppointments = upcoming.sorted { ($0.date ?? .distantFuture) < ($1.date ?? .distantFuture) } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift new file mode 100644 index 0000000..fbfd4cf --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewController.swift @@ -0,0 +1,147 @@ +// +// PastAppointmentListViewController.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import UIKit + +final class PastAppointmentListViewController: UIViewController { + + private static let cardSpacing: CGFloat = 16 + + private let viewModel = PastAppointmentListViewModel() + + private let descriptionLabel: UILabel = { + let label = UILabel() + label.text = "30일 이내의 지난 약속이 보관됩니다.\n약속을 길게 눌러 삭제할 수 있습니다." + label.font = .preferredFont(forTextStyle: .footnote) + label.textColor = .secondaryLabel + label.textAlignment = .center + label.numberOfLines = 0 + return label + }() + + private let scrollView = UIScrollView() + + private lazy var contentStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = Self.cardSpacing + return stack + }() + + private let emptyLabel: UILabel = { + let label = UILabel() + label.text = "지난 약속이 없습니다." + label.font = .preferredFont(forTextStyle: .body) + label.textColor = .secondaryLabel + label.textAlignment = .center + label.isHidden = true + return label + }() + + override func viewDidLoad() { + super.viewDidLoad() + title = "지난 약속" + view.backgroundColor = .systemBackground + setUpLayout() + reloadCards() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + } + + private func setUpLayout() { + [descriptionLabel, scrollView, emptyLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + + contentStack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(contentStack) + + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + descriptionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + descriptionLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + + scrollView.topAnchor.constraint(equalTo: descriptionLabel.bottomAnchor, constant: 16), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + contentStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 8), + contentStack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 40), + contentStack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -40), + contentStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), + contentStack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -80), + + emptyLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emptyLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } + + private func reloadCards(pastAppointments: [AppointmentListItem]? = nil) { + let pastAppointments = pastAppointments ?? viewModel.pastAppointments + + contentStack.arrangedSubviews.forEach { + contentStack.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + emptyLabel.isHidden = !pastAppointments.isEmpty + scrollView.isHidden = pastAppointments.isEmpty + + pastAppointments.forEach { addCard(for: $0) } + } + + private func addCard(for item: AppointmentListItem) { + let card = AppointmentListCard(item: item, accessoryView: nil) + card.onChatTap = { + print("\(item.title) 대화 열기") + } + card.onMapTap = { + print("\(item.title) 지도 열기") + } + card.contextMenuProvider = { [weak self] in + self?.makeContextMenu(id: item.id) ?? UIMenu(children: []) + } + + contentStack.addArrangedSubview(card) + } + + private func makeContextMenu(id: String) -> UIMenu { + guard let item = viewModel.item(id: id) else { return UIMenu(children: []) } + + let notificationAction = UIAction( + title: viewModel.notificationMenuTitle(for: item), + image: UIImage(systemName: viewModel.notificationMenuIcon(for: item)) + ) { [weak self] _ in + self?.viewModel.toggleNotification(id: item.id) + } + + let leaveAction = UIAction( + title: "약속 나가기", + image: UIImage(systemName: "rectangle.portrait.and.arrow.right"), + attributes: .destructive + ) { [weak self] _ in + self?.presentLeaveConfirmAlert(id: item.id, title: item.title) + } + + return UIMenu(children: [notificationAction, leaveAction]) + } + + private func presentLeaveConfirmAlert(id: String, title: String) { + let alert = UIAlertController.leaveConfirmAlert(title: title) { [weak self] in + guard let self else { return } + let updated = viewModel.leave(id: id) + reloadCards(pastAppointments: updated) + } + present(alert, animated: true) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewModel.swift b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewModel.swift new file mode 100644 index 0000000..ba0cc01 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/AppointmentList/PastAppointmentListViewModel.swift @@ -0,0 +1,75 @@ +// +// PastAppointmentListViewModel.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import Foundation + +// MARK: - 지난 약속 화면의 데이터 +// - 약속 시각이 지났고, 지난 지 30일 이내인 약속만 보관 + +final class PastAppointmentListViewModel { + + private(set) var pastAppointments: [AppointmentListItem] = [] + + init() { + loadDummyData() + } + + func toggleNotification(id: String) { + guard let index = pastAppointments.firstIndex(where: { $0.id == id }) else { return } + pastAppointments[index] = toggledNotification(of: pastAppointments[index]) + } + + func item(id: String) -> AppointmentListItem? { + pastAppointments.first { $0.id == id } + } + + func notificationMenuTitle(for item: AppointmentListItem) -> String { + item.isNotificationEnabled ? "알림 끄기" : "알림 켜기" + } + + func notificationMenuIcon(for item: AppointmentListItem) -> String { + item.isNotificationEnabled ? "bell.slash" : "bell" + } + + func leave(id: String) -> [AppointmentListItem] { + pastAppointments.removeAll { $0.id == id } + return pastAppointments + } + + private func toggledNotification(of item: AppointmentListItem) -> AppointmentListItem { + AppointmentListItem( + id: item.id, + title: item.title, + participantCount: item.participantCount, + location: item.location, + date: item.date, + isNotificationEnabled: !item.isNotificationEnabled + ) + } + + private func loadDummyData() { + let allPastAppointments = (1...15).map { index in + AppointmentListItem( + id: "past-\(index)", + title: "지난 약속 \(index)", + participantCount: index % 10 + 1, + location: AppointmentLocation( + title: "테스트 장소 \(index)", + address: "", + coordinate: Coordinate(latitude: 0, longitude: 0) + ), + date: Calendar.current.date(byAdding: .day, value: -index, to: Date()), + isNotificationEnabled: true + ) + } + + pastAppointments = allPastAppointments.sorted { + ($0.date ?? .distantPast) > ($1.date ?? .distantPast) + } + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Component/AppointmentCardBase.swift b/WhereAreYou/WhereAreYou/Presentation/Component/AppointmentCardBase.swift new file mode 100644 index 0000000..51f9d35 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Component/AppointmentCardBase.swift @@ -0,0 +1,92 @@ +// +// AppointmentCardBase.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import UIKit + +// MARK: - 약속 카드의 공통 뼈대 +// - 배경/그림자/코너radius + 제목 + 인원/장소/날짜 3줄을 담당 +// - 제목 오른쪽 액세서리(남은 시간 라벨, 휴지통 버튼 등)는 하위 클래스가 넘겨줌 (없으면 nil) +// - 하위 클래스는 rowStack.bottomAnchor 아래에 자신만의 콘텐츠(버튼, 탭 제스처 등)를 추가 + +class AppointmentCardBase: UIView { + + let rowStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 3 + return stack + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .headline) + label.lineBreakMode = .byTruncatingTail + label.numberOfLines = 1 + return label + }() + + private let participantRow = IconLabel(iconName: "person.2.fill") + private let placeRow = IconLabel(iconName: "location.fill") + private let dateRow = IconLabel(iconName: "calendar.badge.clock") + + init(participantCount: Int, placeText: String, dateText: String, title: String, accessoryView: UIView?) { + super.init(frame: .zero) + setUp(accessoryView: accessoryView) + configure(title: title, participantCount: participantCount, placeText: placeText, dateText: dateText) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setUp(accessoryView: UIView?) { + backgroundColor = .white + layer.cornerRadius = 20 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.25 + layer.shadowRadius = 2 + layer.shadowOffset = CGSize(width: 0, height: 4) + + [participantRow, placeRow, dateRow].forEach { rowStack.addArrangedSubview($0) } + + [titleLabel, rowStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + + rowStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), + rowStack.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor, constant: 6), + rowStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -14) + ]) + + setUpAccessoryViewIfNeeded(accessoryView) + } + + private func setUpAccessoryViewIfNeeded(_ accessoryView: UIView?) { + guard let accessoryView else { return } + accessoryView.translatesAutoresizingMaskIntoConstraints = false + addSubview(accessoryView) + + NSLayoutConstraint.activate([ + accessoryView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + accessoryView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: accessoryView.leadingAnchor, constant: -8) + ]) + } + + private func configure(title: String, participantCount: Int, placeText: String, dateText: String) { + titleLabel.text = title + participantRow.text = "\(participantCount)명" + placeRow.text = placeText + dateRow.text = dateText + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/Date+AppointmentDisplay.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/Date+AppointmentDisplay.swift new file mode 100644 index 0000000..eb5868f --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/Date+AppointmentDisplay.swift @@ -0,0 +1,23 @@ +// +// Date+AppointmentDisplay.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import Foundation + +// MARK: - 약속 날짜/시간을 화면에 표시할 문자열로 변환 +// - 올해면 연도를 생략하고, 올해가 아니면 연도를 포함해 표시 + +extension Date { + + var appointmentDateTimeText: String { + isThisYear ? monthDayTimeString : koreanDateTimeString + } + + private var isThisYear: Bool { + Calendar.current.component(.year, from: self) == Calendar.current.component(.year, from: Date()) + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/Date+RemainingTime.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/Date+RemainingTime.swift index 0b85c14..eb1a6c3 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Extension/Date+RemainingTime.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/Date+RemainingTime.swift @@ -8,17 +8,36 @@ import Foundation // MARK: - 현재 시각 기준 남은 시간을 화면에 표시할 문자열로 변환 -// - 순수 포맷 변환이 아니라 "지금"을 참조하는 프레젠테이션 로직이라 Core가 아닌 Presentation에 위치 +// - 약속 시간이 지났다면(self < 지금) nil을 반환해 "남은 시간 표시 안 함" 규칙을 표현 +// 지남 여부는 Date 값 자체(초 단위)로 판정 — hour 절삭값으로 판정하면 자정 근처 등에서 오판 가능 +// - 안 지났다면 시간(hour) 차이를 기준으로 구간을 나눠 단위를 정함 (달력 월/년 컴포넌트 미사용) +// - 각 구간의 경계는 그 구간에서 쓰는 나눗셈 단위와 맞춰, 정수 나눗셈으로 값이 0이 되는 경우가 없도록 함 +// - 1시간 미만 → "곧 시작" +// - 24시간 미만 → 시간 단위 +// - 30일(24*30시간) 미만 → 일 단위 +// - 365일(24*365시간) 미만 → 개월 단위 (30일 = 1개월로 근사) +// - 그 외 → 연 단위 (365일 = 1년으로 근사) extension Date { - var remainingTimeText: String { + var remainingTimeText: String? { + guard self >= Date() else { return nil } + let hours = Calendar.current.dateComponents([.hour], from: Date(), to: self).hour ?? 0 - if hours >= 24 { + if hours == 0 { + return "곧 시작" + } + if hours < 24 { + return "\(hours)시간 남음" + } + if hours < 24 * 30 { return "\(hours / 24)일 남음" } - return "\(hours)시간 남음" + if hours < 24 * 365 { + return "\(hours / 24 / 30)개월 남음" + } + return "\(hours / 24 / 365)년 남음" } } diff --git a/WhereAreYou/WhereAreYou/Presentation/Extension/UIAlertController+LeaveConfirm.swift b/WhereAreYou/WhereAreYou/Presentation/Extension/UIAlertController+LeaveConfirm.swift new file mode 100644 index 0000000..ebbd3f2 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/Extension/UIAlertController+LeaveConfirm.swift @@ -0,0 +1,26 @@ +// +// UIAlertController+LeaveConfirm.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import UIKit + +// MARK: - 약속 나가기(목록에서 제거) 확인 알럿 +// - 약속 목록/지난 약속 화면에서 동일한 문구·동작으로 재사용 + +extension UIAlertController { + + static func leaveConfirmAlert(title: String, onConfirm: @escaping () -> Void) -> UIAlertController { + let alert = UIAlertController( + title: "약속 나가기", + message: "'\(title)' 약속에서 나가시겠어요?", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "나가기", style: .destructive) { _ in onConfirm() }) + return alert + } + +} diff --git a/WhereAreYou/WhereAreYou/Presentation/Home/UpcomingAppointmentCard.swift b/WhereAreYou/WhereAreYou/Presentation/Home/UpcomingAppointmentCard.swift index edfb5e6..f8bf36b 100644 --- a/WhereAreYou/WhereAreYou/Presentation/Home/UpcomingAppointmentCard.swift +++ b/WhereAreYou/WhereAreYou/Presentation/Home/UpcomingAppointmentCard.swift @@ -11,90 +11,38 @@ import UIKit // - 제목 + 남은 시간(옵셔널) + 인원/장소/날짜 3줄로 구성 // - 카드 자체가 탭 가능 -final class UpcomingAppointmentCard: UIView { +final class UpcomingAppointmentCard: AppointmentCardBase { var onTap: (() -> Void)? - private let titleLabel: UILabel = { - let label = UILabel() - label.font = UIFont.preferredFont(forTextStyle: .headline) - label.lineBreakMode = .byTruncatingTail - label.numberOfLines = 1 - return label - }() - - private let remainingTimeLabel: UILabel = { - let label = UILabel() - label.font = .boldPreferredFont(forTextStyle: .footnote) - label.textColor = .blue2 - label.adjustsFontForContentSizeCategory = true - label.setContentHuggingPriority(.required, for: .horizontal) - label.setContentCompressionResistancePriority(.required, for: .horizontal) - return label - }() - - private let participantRow = IconLabel(iconName: "person.2.fill") - private let placeRow = IconLabel(iconName: "location.fill") - private let dateRow = IconLabel(iconName: "calendar.badge.clock") - - private lazy var rowStack: UIStackView = { - let stack = UIStackView(arrangedSubviews: [participantRow, placeRow, dateRow]) - stack.axis = .vertical - stack.spacing = 3 - return stack - }() - init(appointment: UpcomingAppointment) { - super.init(frame: .zero) - setUp() - configure(with: appointment) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented — use init(appointment:)") - } - - private func setUp() { - backgroundColor = .white - layer.cornerRadius = 20 - layer.shadowColor = UIColor.black.cgColor - layer.shadowOpacity = 0.25 - layer.shadowRadius = 2 - layer.shadowOffset = CGSize(width: 0, height: 4) - - titleLabel.translatesAutoresizingMaskIntoConstraints = false - remainingTimeLabel.translatesAutoresizingMaskIntoConstraints = false - rowStack.translatesAutoresizingMaskIntoConstraints = false - addSubview(titleLabel) - addSubview(remainingTimeLabel) - addSubview(rowStack) - - NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), - titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), - titleLabel.trailingAnchor.constraint( - lessThanOrEqualTo: remainingTimeLabel.leadingAnchor, constant: -8 - ), - - remainingTimeLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - remainingTimeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), - - rowStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8), - rowStack.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor, constant: 6), - rowStack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -14), - rowStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14) - ]) + let remainingTimeLabel: UILabel = { + let label = UILabel() + label.font = .boldPreferredFont(forTextStyle: .footnote) + label.textColor = .blue2 + label.adjustsFontForContentSizeCategory = true + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + label.text = appointment.date?.remainingTimeText + return label + }() + + super.init( + participantCount: appointment.participantCount, + placeText: appointment.location?.title ?? "미정", + dateText: appointment.date?.appointmentDateTimeText ?? "미정", + title: appointment.title, + accessoryView: remainingTimeLabel + ) + + rowStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14).isActive = true isUserInteractionEnabled = true addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped))) } - private func configure(with appointment: UpcomingAppointment) { - titleLabel.text = appointment.title - remainingTimeLabel.text = appointment.date?.remainingTimeText - participantRow.text = "\(appointment.participantCount)명" - placeRow.text = appointment.location?.title ?? "미정" - dateRow.text = appointment.date?.koreanDateTimeString ?? "미정" + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented — use init(appointment:)") } @objc private func cardTapped() { diff --git a/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentListItem.swift b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentListItem.swift new file mode 100644 index 0000000..1913209 --- /dev/null +++ b/WhereAreYou/WhereAreYou/Presentation/PresentationModel/AppointmentListItem.swift @@ -0,0 +1,19 @@ +// +// AppointmentListItem.swift +// WhereAreYou +// +// Created by 김성훈 on 7/21/26. +// + +import Foundation + +// MARK: - 약속 목록 / 지난 약속 화면의 카드 하나에 대응하는 표시 전용 모델 + +struct AppointmentListItem { + let id: String + let title: String + let participantCount: Int + let location: AppointmentLocation? + let date: Date? + let isNotificationEnabled: Bool +}