-
Notifications
You must be signed in to change notification settings - Fork 0
[Work 22] 약속 생성 화면을 구성했습니다. #8
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
23 commits
Select commit
Hold shift + click to select a range
a4d8504
DESIGN: 서비스 로고 이미지 수정
sangYuLv 1852f03
DESIGN: 약속 이름 입력 placeholder 수정
sangYuLv 58481eb
RENAME: 약속 생성 서브 뷰 폴더링
sangYuLv 590e885
FEAT: 약속 생성 초기 view model
sangYuLv 62ecb9a
FEAT: 날짜 피커 시트 재사용
sangYuLv 40d03b6
DESIGN: 장소 검색 카드 재사용 (.onlyHeader 스타일 추가)
sangYuLv 3ccbe9a
DESIGN: 약속 생성 view controller
sangYuLv ffbde16
FEAT: 약속 생성 repository (+ mock 구현체)
sangYuLv b0eeedf
FEAT: 약속 생성 use case
sangYuLv 725adad
FEAT: 약속 생성 use case 연결
sangYuLv 16fdeb7
DESIGN: 약속 생성 후 확인 카드 표시
sangYuLv 49ad0c9
REFACTOR: 액션 연결 방식 통일 (addTarget -> addAction)
sangYuLv 90fdadc
FIX: 장소 검색 결과 반영 전 결과 없음 텍스트 보이는 문제 해결
sangYuLv 36c5547
FIX: sheet(medium)에서 장소 검색 카드가 mini 기종에서 압축되는 문제 해결
sangYuLv 55d5748
DESIGN: mini 기종에 맞게 버튼 텍스트 크기 조정
sangYuLv 717638c
DOCS: SearchPlaceCardViewController.Style 주석
sangYuLv c8207d6
DOCS: 약속 생성 실패 대응 관련 TODO 주석
sangYuLv 5f92c58
FIX: 장소 검색 완료 후 결과가 빈 상태에 대한 대응 추가
sangYuLv 153d367
FIX: 중복 약속 생성 방지
sangYuLv 6c8c8d5
DOCS: 메소드 순서 정렬
sangYuLv 42f75e6
FIX: 날짜 피커에 초기 Date를 입력 받아 사용자가 지정했던 날짜가 보이도록 수정
sangYuLv 2a9c1e1
FIX: addAction에서 sender 타입 안전성 확보 (FilterCapsule)
sangYuLv 130fa2a
FIX: UI 수정 작업의 메인 스레드 보장
sangYuLv 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
35 changes: 35 additions & 0 deletions
35
WhereAreYou/WhereAreYou/Data/MockAppointmentCreationRepository.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,35 @@ | ||
| // | ||
| // MockAppointmentCreationRepository.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 이상유 on 2026-07-24. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| final class MockAppointmentCreationRepository: AppointmentCreationRepository { | ||
|
|
||
| func createAppointment( | ||
| title: String, | ||
| date: Date?, | ||
| place: Place?, | ||
| completion: @escaping (Result<Appointment, Error>) -> Void | ||
| ) { | ||
| let code = String(UUID().uuidString.prefix(8)) | ||
| let appointment = Appointment( | ||
| id: UUID().uuidString, | ||
| code: code, | ||
| name: title, | ||
| dateTime: date ?? Date(), | ||
| place: place, | ||
| participants: [], | ||
| routes: [:], | ||
| placeVoteStatus: [:] | ||
| ) | ||
|
|
||
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { | ||
| completion(.success(appointment)) | ||
| } | ||
| } | ||
|
|
||
| } |
19 changes: 19 additions & 0 deletions
19
WhereAreYou/WhereAreYou/Domain/Repository/AppointmentCreationRepository.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,19 @@ | ||
| // | ||
| // AppointmentCreationRepository.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 이상유 on 2026-07-24. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| protocol AppointmentCreationRepository { | ||
|
|
||
| func createAppointment( | ||
| title: String, | ||
| date: Date?, | ||
| place: Place?, | ||
| completion: @escaping (Result<Appointment, Error>) -> Void | ||
| ) | ||
|
|
||
| } |
32 changes: 32 additions & 0 deletions
32
WhereAreYou/WhereAreYou/Domain/UseCase/CreateAppointmentUseCase.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,32 @@ | ||
| // | ||
| // CreateAppointmentUseCase.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 이상유 on 2026-07-24. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| final class CreateAppointmentUseCase { | ||
|
|
||
| private let repository: AppointmentCreationRepository | ||
|
|
||
| init(repository: AppointmentCreationRepository) { | ||
| self.repository = repository | ||
| } | ||
|
|
||
| func execute( | ||
| title: String, | ||
| date: Date?, | ||
| place: Place?, | ||
| completion: @escaping (Result<Appointment, Error>) -> Void | ||
| ) { | ||
| repository.createAppointment( | ||
| title: title, | ||
| date: date, | ||
| place: place, | ||
| completion: completion | ||
| ) | ||
| } | ||
|
|
||
| } |
190 changes: 190 additions & 0 deletions
190
...eYou/WhereAreYou/Presentation/AppointmentCreation/AppointmentCreationViewController.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,190 @@ | ||
| // | ||
| // AppointmentCreationViewController.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 이상유 on 2026-07-24. | ||
| // | ||
|
|
||
| import UIKit | ||
| import Combine | ||
|
|
||
| final class AppointmentCreationViewController: UIViewController { | ||
|
|
||
| private let viewModel: AppointmentCreationViewModel | ||
| private var cancellables = Set<AnyCancellable>() | ||
|
|
||
| private let logoImage: UIImageView = { | ||
| let imageView = UIImageView(image: .logo) | ||
| imageView.contentMode = .scaleAspectFit | ||
| return imageView | ||
| }() | ||
|
|
||
| private let creationCard = AppointmentCreateCard() | ||
|
|
||
| private var confirmCard: AppointmentConfirmCard? | ||
|
|
||
| init(_ viewModel: AppointmentCreationViewModel) { | ||
| self.viewModel = viewModel | ||
| super.init(nibName: nil, bundle: nil) | ||
| } | ||
|
|
||
| required init?(coder: NSCoder) { | ||
| fatalError("init(coder:) has not been implemented") | ||
| } | ||
|
|
||
| override func viewDidLoad() { | ||
| super.viewDidLoad() | ||
| view.backgroundColor = .pointBackground | ||
| title = "약속 생성" | ||
| setUpLayout() | ||
| setUpActions() | ||
| bindViewModel() | ||
| } | ||
|
|
||
| private func setUpLayout() { | ||
| logoImage.translatesAutoresizingMaskIntoConstraints = false | ||
| creationCard.translatesAutoresizingMaskIntoConstraints = false | ||
|
|
||
| view.addSubview(logoImage) | ||
| view.addSubview(creationCard) | ||
|
|
||
| NSLayoutConstraint.activate([ | ||
| logoImage.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), | ||
| logoImage.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), | ||
| logoImage.widthAnchor.constraint(equalToConstant: 77), | ||
| logoImage.heightAnchor.constraint(equalToConstant: 48), | ||
|
|
||
| creationCard.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -50), | ||
| creationCard.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 45), | ||
| creationCard.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -45), | ||
| ]) | ||
| } | ||
|
|
||
| private func setUpActions() { | ||
| creationCard.onNameChanged = { [weak self] new in | ||
| self?.viewModel.setTitle(new) | ||
| } | ||
| creationCard.onDateRowTap = { [weak self] in | ||
| self?.presentDatePicker() | ||
| } | ||
| creationCard.onPlaceRowTap = { [weak self] in | ||
| self?.presentPlaceSearch() | ||
| } | ||
| creationCard.onMapButtonTap = { | ||
| // TODO: 지도 화면 작업 후 연결 | ||
| } | ||
| creationCard.onCreateTap = { [weak self] in | ||
| self?.viewModel.create() | ||
| } | ||
| } | ||
|
|
||
| private func bindViewModel() { | ||
| viewModel.$appointmentDate | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] date in | ||
| guard let self, let date else { return } | ||
| self.creationCard.setDateLabel(date) | ||
| } | ||
| .store(in: &cancellables) | ||
|
|
||
| viewModel.$appointmentPlace | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] place in | ||
| guard let self, let place else { return } | ||
| self.creationCard.setPlaceLabel(place) | ||
| } | ||
| .store(in: &cancellables) | ||
|
|
||
| viewModel.$hasCreated | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] hasCreated in | ||
| guard let self, hasCreated else { return } | ||
| self.showConfirmCard() | ||
| } | ||
| .store(in: &cancellables) | ||
| } | ||
|
|
||
| } | ||
|
|
||
|
|
||
| extension AppointmentCreationViewController { | ||
|
|
||
| // MARK: - Confirm Card Transition | ||
|
|
||
| private func showConfirmCard() { | ||
| let info = viewModel.makeAppointmentInfo() | ||
| let card = AppointmentConfirmCard(data: info) | ||
| self.confirmCard = card | ||
|
|
||
| card.onCopyCodeTap = { | ||
| UIPasteboard.general.string = info.code | ||
| } | ||
| card.onConfirmTap = { | ||
| // TODO: 약속 화면으로 이동 | ||
| } | ||
|
|
||
| view.addSubview(card) | ||
| NSLayoutConstraint.activate([ | ||
| card.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor, constant: -50), | ||
| card.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 45), | ||
| card.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -45), | ||
| ]) | ||
|
|
||
| card.transform = CGAffineTransform(translationX: view.bounds.width, y: 0) | ||
| card.alpha = 0 | ||
|
|
||
| UIView.animate(withDuration: 0.45, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.5) { | ||
| self.creationCard.transform = CGAffineTransform(translationX: -self.view.bounds.width, y: 0) | ||
| self.creationCard.alpha = 0 | ||
| card.transform = .identity | ||
| card.alpha = 1 | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Date Picker | ||
|
|
||
| private func presentDatePicker() { | ||
| let pickerViewController = DatePickerSheetViewController( | ||
| title: "약속 날짜/시간 설정", | ||
| initialDate: viewModel.appointmentDate | ||
| ) | ||
| pickerViewController.onDateSelected = { [weak self] date in | ||
| self?.viewModel.setDate(date) | ||
| } | ||
|
|
||
| if let sheet = pickerViewController.sheetPresentationController { | ||
| sheet.detents = [.medium()] | ||
| sheet.prefersGrabberVisible = true | ||
| } | ||
|
|
||
| present(pickerViewController, animated: true) | ||
| } | ||
|
|
||
|
|
||
| // MARK: - Search Place Card | ||
|
|
||
| private func presentPlaceSearch() { | ||
| let searchPlaceViewController = SearchPlaceCardViewController( | ||
| viewModel: SearchPlaceCardViewModel( | ||
| searchPlacesUseCase: SearchPlacesUseCase( | ||
| repository: MockPlaceSearchRepository() | ||
| ) | ||
| ), | ||
| selectionButtonTitle: "선택하기", | ||
| style: .onlyHeader(title: "약속 장소 검색") | ||
| ) | ||
|
|
||
| searchPlaceViewController.onPlaceSelected = { [weak self] place in | ||
| self?.viewModel.setPlace(place) | ||
| searchPlaceViewController.dismiss(animated: true) | ||
| } | ||
|
|
||
| if let sheet = searchPlaceViewController.sheetPresentationController { | ||
| sheet.detents = [.medium()] | ||
| sheet.prefersGrabberVisible = true | ||
| } | ||
|
|
||
| present(searchPlaceViewController, animated: true) | ||
| } | ||
|
|
||
| } |
84 changes: 84 additions & 0 deletions
84
WhereAreYou/WhereAreYou/Presentation/AppointmentCreation/AppointmentCreationViewModel.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,84 @@ | ||
| // | ||
| // AppointmentCreationViewModel.swift | ||
| // WhereAreYou | ||
| // | ||
| // Created by 이상유 on 2026-07-24. | ||
| // | ||
|
|
||
| import Foundation | ||
| import Combine | ||
|
|
||
| final class AppointmentCreationViewModel { | ||
|
|
||
| private let createAppointmentUseCase: CreateAppointmentUseCase | ||
|
|
||
| private var appointmentTitle: String | ||
| private var id: String? | ||
| private var code: String? | ||
| private var isCreating = false | ||
|
|
||
| @Published private(set) var appointmentDate: Date? | ||
| @Published private(set) var appointmentPlace: Place? | ||
| @Published private(set) var hasCreated: Bool = false | ||
|
|
||
| init(createAppointmentUseCase: CreateAppointmentUseCase) { | ||
| self.createAppointmentUseCase = createAppointmentUseCase | ||
| appointmentTitle = "약속" | ||
| } | ||
|
|
||
| func setTitle(_ title: String) { | ||
| appointmentTitle = title | ||
| } | ||
|
|
||
| func setDate(_ date: Date) { | ||
| appointmentDate = date | ||
| } | ||
|
|
||
| func setPlace(_ place: Place) { | ||
| appointmentPlace = place | ||
| } | ||
|
|
||
| func create() { | ||
| guard !isCreating, !hasCreated else { return } | ||
|
sangYuLv marked this conversation as resolved.
|
||
| isCreating = true | ||
| if appointmentTitle.isEmpty { appointmentTitle = "약속" } | ||
|
|
||
| createAppointmentUseCase.execute( | ||
| title: appointmentTitle, | ||
| date: appointmentDate, | ||
| place: appointmentPlace | ||
| ) { [weak self] result in | ||
| guard let self else { return } | ||
| DispatchQueue.main.async { | ||
| self.isCreating = false | ||
| switch result { | ||
| case .success(let appointment): | ||
| self.id = appointment.id | ||
| self.code = appointment.code | ||
| self.hasCreated = true | ||
| case .failure: | ||
| // TODO: 서버 작업 후 실패 케이스 정리해 사용자에게 표시할 실패 텍스트 추가 예정 | ||
| break | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func makeAppointmentInfo() -> AppointmentInfo { | ||
| AppointmentInfo( | ||
| id: id ?? "", | ||
| code: code ?? "", | ||
| title: appointmentTitle, | ||
| date: appointmentDate, | ||
| location: appointmentPlace.map { | ||
| AppointmentLocation( | ||
| title: $0.name, | ||
| address: $0.address, | ||
| coordinate: $0.coordinate | ||
| ) | ||
| }, | ||
| participants: [] | ||
| ) | ||
| } | ||
|
|
||
| } | ||
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
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.