Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a4d8504
DESIGN: 서비스 로고 이미지 수정
sangYuLv Jul 24, 2026
1852f03
DESIGN: 약속 이름 입력 placeholder 수정
sangYuLv Jul 24, 2026
58481eb
RENAME: 약속 생성 서브 뷰 폴더링
sangYuLv Jul 24, 2026
590e885
FEAT: 약속 생성 초기 view model
sangYuLv Jul 24, 2026
62ecb9a
FEAT: 날짜 피커 시트 재사용
sangYuLv Jul 24, 2026
40d03b6
DESIGN: 장소 검색 카드 재사용 (.onlyHeader 스타일 추가)
sangYuLv Jul 24, 2026
3ccbe9a
DESIGN: 약속 생성 view controller
sangYuLv Jul 24, 2026
ffbde16
FEAT: 약속 생성 repository (+ mock 구현체)
sangYuLv Jul 24, 2026
b0eeedf
FEAT: 약속 생성 use case
sangYuLv Jul 24, 2026
725adad
FEAT: 약속 생성 use case 연결
sangYuLv Jul 24, 2026
16fdeb7
DESIGN: 약속 생성 후 확인 카드 표시
sangYuLv Jul 24, 2026
49ad0c9
REFACTOR: 액션 연결 방식 통일 (addTarget -> addAction)
sangYuLv Jul 24, 2026
90fdadc
FIX: 장소 검색 결과 반영 전 결과 없음 텍스트 보이는 문제 해결
sangYuLv Jul 24, 2026
36c5547
FIX: sheet(medium)에서 장소 검색 카드가 mini 기종에서 압축되는 문제 해결
sangYuLv Jul 24, 2026
55d5748
DESIGN: mini 기종에 맞게 버튼 텍스트 크기 조정
sangYuLv Jul 24, 2026
717638c
DOCS: SearchPlaceCardViewController.Style 주석
sangYuLv Jul 26, 2026
c8207d6
DOCS: 약속 생성 실패 대응 관련 TODO 주석
sangYuLv Jul 26, 2026
5f92c58
FIX: 장소 검색 완료 후 결과가 빈 상태에 대한 대응 추가
sangYuLv Jul 26, 2026
153d367
FIX: 중복 약속 생성 방지
sangYuLv Jul 26, 2026
6c8c8d5
DOCS: 메소드 순서 정렬
sangYuLv Jul 26, 2026
42f75e6
FIX: 날짜 피커에 초기 Date를 입력 받아 사용자가 지정했던 날짜가 보이도록 수정
sangYuLv Jul 26, 2026
2a9c1e1
FIX: addAction에서 sender 타입 안전성 확보 (FilterCapsule)
sangYuLv Jul 26, 2026
130fa2a
FIX: UI 수정 작업의 메인 스레드 보장
sangYuLv Jul 26, 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
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))
}
}

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

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

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

}
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() {
Comment thread
sangYuLv marked this conversation as resolved.
guard !isCreating, !hasCreated else { return }
Comment thread
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: []
)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ final class AppointmentConfirmCard: UIView {
return label
}()

private lazy var copyButton = UIButton.filled(title: "코드 복사하기", background: .systemGray2, tint: .white)
private lazy var confirmButton = UIButton.filled(title: "약속으로 이동하기", background: .blue2, tint: .white)
private lazy var copyButton = UIButton.filled(title: "코드 복사하기", background: .systemGray2, tint: .white, font: .subheadline)
private lazy var confirmButton = UIButton.filled(title: "약속으로 이동하기", background: .blue2, tint: .white, font: .subheadline)

private let footerStack: UIStackView = {
let stack = UIStackView()
Expand Down Expand Up @@ -78,8 +78,8 @@ final class AppointmentConfirmCard: UIView {
card.contentStack.addArrangedSubview(codeLabel)
card.contentStack.addArrangedSubview(footerStack)

copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
copyButton.addAction(UIAction { [weak self] _ in self?.onCopyCodeTap?() }, for: .touchUpInside)
confirmButton.addAction(UIAction { [weak self] _ in self?.onConfirmTap?() }, for: .touchUpInside)
}

private func configure(with data: AppointmentInfo) {
Expand All @@ -89,7 +89,4 @@ final class AppointmentConfirmCard: UIView {
codeLabel.text = "약속 코드 : \(data.code)"
}

@objc private func copyTapped() { onCopyCodeTap?() }
@objc private func confirmTapped() { onConfirmTap?() }

}
Loading
Loading