-
Notifications
You must be signed in to change notification settings - Fork 0
[Work 26] 약속 목록 화면의 UI를 구성했습니다. #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
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 d8fe8f2
FEAT: 약속 날짜가 올해가 아니면 연도를 표시하는 appointmentDateTimeText 추가
snughnu 91910eb
FIX: 홈 화면 약속 카드도 올해가 아닐 때만 연도를 표시하도록 통일
snughnu e9f9594
FEAT: 약속 목록 화면용 AppointmentListItem 모델 추가
snughnu a97ec10
REFACTOR: 약속 카드 공통 로직을 AppointmentCardBase로 추출하고 AppointmentListCard …
snughnu de81469
FEAT: AppointmentListCard에 꾹 눌러 알림 토글/약속 나가기 컨텍스트 메뉴 추가
snughnu 302b4ad
FEAT: 약속 목록 화면용 AppointmentListViewModel 추가
snughnu cdb26de
FEAT: AppointmentListViewController 레이아웃 및 오늘/예정 약속 카드 표시 추가
snughnu de3aaa6
FEAT: 지난 약속 화면용 PastAppointmentListViewModel 추가
snughnu 418d634
FEAT: PastAppointmentListViewController 레이아웃 및 지난 약속 카드/삭제 흐름 추가
snughnu 996c503
FEAT: 약속 목록 화면에서 지난 약속 화면으로 실제 push 연결
snughnu e59eb70
DESIGN: AppointmentListCard 버튼 색상/아이콘 스타일 조정
snughnu bd41619
REFACTOR: 지난 약속 카드 삭제 버튼을 제거하고 나가기 확인 얼럿을 공용 extension으로 통합
snughnu 3e39b2b
DESIGN: 지난 약속 이동 버튼 스타일 수정
snughnu d89ea53
DESIGN: 지난 약속 화면에 롱프레스 삭제 안내 문구 추가
snughnu 42ddaf8
DESIGN: 약속 목록/지난 약속 카드의 좌우 여백을 홈 화면과 동일하게 40pt로 통일
snughnu b2eb940
DESIGN: AppointmentListCard 대화/지도 버튼 높이와 모서리 radius 조정
snughnu 48a233c
DESIGN: 약속 목록 화면 헤더(로고/타이틀 색상) 및 내비게이션 바 표시 전환 정리
snughnu 9ca85c4
DESIGN: 약속 목록 더미데이터 및 섹션 헤더 레이아웃 조정
snughnu b7fe7e8
FIX: 남은 시간 계산의 0시간/0일 버그와 지난 약속 오판 수정, 곧 시작 문구 추가
snughnu cb5d45f
DESIGN: 지난 약속 화면 더미데이터를 15개로 늘려 스크롤 확인 가능하게 변경
snughnu 18f800f
REFACTOR: 카드 컨텍스트 메뉴의 상태 판단을 ViewModel로 이동
snughnu 7626db5
REFACTOR: UIImage(named:) 대신 .logo 타입 세이프 접근 사용
snughnu e0cae79
REFACTOR: 토글/나가기 반환값 활용 및 지난 약속 삭제 명칭을 나가기로 통일
snughnu 0c0f2b7
REFACTOR: 알림 토글 시 화면 재생성 없이 ViewModel 상태만 갱신하도록 변경
snughnu 2bacfde
FIX: AppointmentListCard의 preferredFont 타입 추론 모호성으로 인한 빌드 실패 수정
snughnu 31bb4f5
Merge branch 'develop' into WORK-26
snughnu 941363a
FIX: UIButton.filled 시그니처 변경에 맞춰 AppointmentListCard 버튼 수정
snughnu 2a8f8ff
Merge branch 'develop' into WORK-26
snughnu File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
110 changes: 110 additions & 0 deletions
110
WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListCard.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() { | ||
| addInteraction(UIContextMenuInteraction(delegate: self)) | ||
| } | ||
|
|
||
| } | ||
|
|
||
| // MARK: - UIContextMenuInteractionDelegate | ||
|
|
||
| extension AppointmentListCard: UIContextMenuInteractionDelegate { | ||
|
|
||
| func contextMenuInteraction( | ||
| _ interaction: UIContextMenuInteraction, | ||
| configurationForMenuAtLocation location: CGPoint | ||
| ) -> UIContextMenuConfiguration? { | ||
| UIContextMenuConfiguration(actionProvider: { [weak self] _ in self?.contextMenuProvider?() }) | ||
| } | ||
|
|
||
| } | ||
248 changes: 248 additions & 0 deletions
248
WhereAreYou/WhereAreYou/Presentation/AppointmentList/AppointmentListViewController.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() | ||
|
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) | ||
| } | ||
|
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) | ||
| } | ||
|
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.