Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
af072b9
FEAT: remainingTimeText에 12시간/30일/365일 경계 및 지난 약속 nil 처리 추가
snughnu Jul 21, 2026
d8fe8f2
FEAT: 약속 날짜가 올해가 아니면 연도를 표시하는 appointmentDateTimeText 추가
snughnu Jul 21, 2026
91910eb
FIX: 홈 화면 약속 카드도 올해가 아닐 때만 연도를 표시하도록 통일
snughnu Jul 21, 2026
e9f9594
FEAT: 약속 목록 화면용 AppointmentListItem 모델 추가
snughnu Jul 21, 2026
a97ec10
REFACTOR: 약속 카드 공통 로직을 AppointmentCardBase로 추출하고 AppointmentListCard …
snughnu Jul 21, 2026
de81469
FEAT: AppointmentListCard에 꾹 눌러 알림 토글/약속 나가기 컨텍스트 메뉴 추가
snughnu Jul 21, 2026
302b4ad
FEAT: 약속 목록 화면용 AppointmentListViewModel 추가
snughnu Jul 21, 2026
cdb26de
FEAT: AppointmentListViewController 레이아웃 및 오늘/예정 약속 카드 표시 추가
snughnu Jul 21, 2026
de3aaa6
FEAT: 지난 약속 화면용 PastAppointmentListViewModel 추가
snughnu Jul 21, 2026
418d634
FEAT: PastAppointmentListViewController 레이아웃 및 지난 약속 카드/삭제 흐름 추가
snughnu Jul 21, 2026
996c503
FEAT: 약속 목록 화면에서 지난 약속 화면으로 실제 push 연결
snughnu Jul 21, 2026
e59eb70
DESIGN: AppointmentListCard 버튼 색상/아이콘 스타일 조정
snughnu Jul 21, 2026
bd41619
REFACTOR: 지난 약속 카드 삭제 버튼을 제거하고 나가기 확인 얼럿을 공용 extension으로 통합
snughnu Jul 21, 2026
3e39b2b
DESIGN: 지난 약속 이동 버튼 스타일 수정
snughnu Jul 21, 2026
d89ea53
DESIGN: 지난 약속 화면에 롱프레스 삭제 안내 문구 추가
snughnu Jul 21, 2026
42ddaf8
DESIGN: 약속 목록/지난 약속 카드의 좌우 여백을 홈 화면과 동일하게 40pt로 통일
snughnu Jul 21, 2026
b2eb940
DESIGN: AppointmentListCard 대화/지도 버튼 높이와 모서리 radius 조정
snughnu Jul 21, 2026
48a233c
DESIGN: 약속 목록 화면 헤더(로고/타이틀 색상) 및 내비게이션 바 표시 전환 정리
snughnu Jul 21, 2026
9ca85c4
DESIGN: 약속 목록 더미데이터 및 섹션 헤더 레이아웃 조정
snughnu Jul 21, 2026
b7fe7e8
FIX: 남은 시간 계산의 0시간/0일 버그와 지난 약속 오판 수정, 곧 시작 문구 추가
snughnu Jul 21, 2026
cb5d45f
DESIGN: 지난 약속 화면 더미데이터를 15개로 늘려 스크롤 확인 가능하게 변경
snughnu Jul 21, 2026
18f800f
REFACTOR: 카드 컨텍스트 메뉴의 상태 판단을 ViewModel로 이동
snughnu Jul 24, 2026
7626db5
REFACTOR: UIImage(named:) 대신 .logo 타입 세이프 접근 사용
snughnu Jul 24, 2026
e0cae79
REFACTOR: 토글/나가기 반환값 활용 및 지난 약속 삭제 명칭을 나가기로 통일
snughnu Jul 24, 2026
0c0f2b7
REFACTOR: 알림 토글 시 화면 재생성 없이 ViewModel 상태만 갱신하도록 변경
snughnu Jul 24, 2026
2bacfde
FIX: AppointmentListCard의 preferredFont 타입 추론 모호성으로 인한 빌드 실패 수정
snughnu Jul 24, 2026
31bb4f5
Merge branch 'develop' into WORK-26
snughnu Jul 24, 2026
941363a
FIX: UIButton.filled 시그니처 변경에 맞춰 AppointmentListCard 버튼 수정
snughnu Jul 24, 2026
2a8f8ff
Merge branch 'develop' into WORK-26
snughnu Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions WhereAreYou/WhereAreYou/Core/Date+Format.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Comment thread
snughnu marked this conversation as resolved.
addInteraction(UIContextMenuInteraction(delegate: self))
}

}

// MARK: - UIContextMenuInteractionDelegate

extension AppointmentListCard: UIContextMenuInteractionDelegate {

func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.contextMenuProvider?() })
}

}
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
snughnu marked this conversation as resolved.

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)
}
Comment thread
snughnu marked this conversation as resolved.

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)
}

}
Loading
Loading