From 1ade99e3179aa6b9920a5d324502eb3cc6ba6485 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Fri, 17 Oct 2025 20:49:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat.=20=EC=98=88=EB=A7=A4=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20UI=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/BookingOptionsCard.swift | 125 ++++++++++++++++ .../Components/PaymentInfoCard.swift | 137 ++++++++++++++++++ .../Feature/MovieBook/MovieBookView.swift | 60 ++++++++ 3 files changed, 322 insertions(+) create mode 100644 MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift create mode 100644 MovieBooking/Feature/MovieBook/Components/PaymentInfoCard.swift create mode 100644 MovieBooking/Feature/MovieBook/MovieBookView.swift diff --git a/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift new file mode 100644 index 0000000..e79da42 --- /dev/null +++ b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift @@ -0,0 +1,125 @@ +// +// BookingOptionsCard.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import SwiftUI + +struct BookingOptionsCard: View { + let theaters: [String] + let times: [String] + @Binding var selectedTheater: String + @Binding var selectedTime: String + @Binding var numberOfPeople: Int + + var body: some View { + VStack(spacing: 20) { + // 극장 선택 + CustomPickerRow( + title: "극장", + selection: $selectedTheater, + options: theaters + ) + + // 상영시간 선택 + CustomPickerRow( + title: "상영시간", + selection: $selectedTime, + options: times + ) + + // 인원수 선택 + CustomPickerRow( + title: "인원수", + selection: $numberOfPeople, + options: Array(1...10), + displayFormatter: { "\($0)명" } + ) + } + .padding(20) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.1), radius: 10, x: 0, y: 5) + } +} + +// MARK: - Custom Picker Row +fileprivate struct CustomPickerRow: View { + let title: String + @Binding var selection: T + let options: [T] + var displayFormatter: ((T) -> String)? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + + Menu { + ForEach(options, id: \.self) { option in + Button { + selection = option + } label: { + HStack { + Text(displayText(for: option)) + if selection == option { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.basicPurple) + } + } + } + } + } label: { + HStack { + Text(displayText(for: selection)) + .foregroundColor(.primary) + .font(.system(size: 16)) + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .padding(14) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } + } + } + + private func displayText(for option: T) -> String { + if let formatter = displayFormatter { + return formatter(option) + } + return "\(option)" + } +} + +#Preview { + BookingOptionsCardPreview() +} + +private struct BookingOptionsCardPreview: View { + @State var selectedTheater = "CGV 강남" + @State var selectedTime = "14:30" + @State var numberOfPeople = 1 + + var body: some View { + BookingOptionsCard( + theaters: ["CGV 강남", "메가박스 코엑스", "롯데시네마 월드타워", "CGV 용산"], + times: ["10:00", "12:30", "14:30", "17:00", "19:30", "22:00"], + selectedTheater: $selectedTheater, + selectedTime: $selectedTime, + numberOfPeople: $numberOfPeople + ) + .padding() + } +} diff --git a/MovieBooking/Feature/MovieBook/Components/PaymentInfoCard.swift b/MovieBooking/Feature/MovieBook/Components/PaymentInfoCard.swift new file mode 100644 index 0000000..daaa60b --- /dev/null +++ b/MovieBooking/Feature/MovieBook/Components/PaymentInfoCard.swift @@ -0,0 +1,137 @@ +// +// PaymentInfoCard.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import SwiftUI + +struct PaymentInfoCard: View { + let posterPath: String + let title: String + let pricePerTicket: Int + let numberOfPeople: Int + let onPaymentTapped: () -> Void + + private var totalPrice: Int { + pricePerTicket * numberOfPeople + } + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 16) { + // 헤더: 로고 + 결제 정보 + HStack { + Image(systemName: "creditcard") + .font(.system(size: 24)) + .foregroundColor(.basicPurple) + + Text("결제 정보") + .font(.system(size: 18, weight: .light)) + + Spacer() + } + + // 이미지 + 타이틀 + HStack(alignment: .top, spacing: 16) { + AsyncImage(url: URL(string: "https://image.tmdb.org/t/p/w500\(posterPath)")) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + Color.gray + case .empty: + ProgressView() + @unknown default: + EmptyView() + } + } + .frame(width: 70, height: 70) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + Text(title) + .font(.system(size: 18, weight: .light)) + .lineLimit(2) + + Spacer() + } + + Divider() + .overlay(Color.white) + + HStack { + Text("일반 관람권 (\(numberOfPeople)매)") + + Spacer() + + Text("\(pricePerTicket.formatted()) 원") + } + .font(.system(size: 16, weight: .light)) + .foregroundColor(.secondary) + + Divider() + .overlay(Color.white) + + // 총 결제금액 + HStack { + Text("총 결제금액") + .font(.system(size: 18, weight: .regular)) + + Spacer() + + Text("\(totalPrice.formatted()) 원") + .font(.system(size: 24, weight: .regular)) + .foregroundColor(.basicPurple) + } + } + .padding(20) + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color.basicPurple.opacity(0.1), + Color.basicPurple.opacity(0.2), + Color.basicPurple.opacity(0.25), + Color.basicPurple.opacity(0.2), + Color.basicPurple.opacity(0.1) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.basicPurple.opacity(0.15), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.2), radius: 2, x: 0, y: 3) + + // 결제하기 버튼 + Button(action: onPaymentTapped) { + Text("결제하기") + .frame(maxWidth: .infinity) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(Color.white) + .padding(.vertical, 16) + .background(Color.basicPurple) + .clipShape(Capsule()) + } + } + } +} + +#Preview { + PaymentInfoCard( + posterPath: "/bUrReoZFLGti6ehkBW0xw8f12MT.jpg", + title: "The Dark Knight", + pricePerTicket: 13000, + numberOfPeople: 2, + onPaymentTapped: { + print("결제하기 버튼 눌림") + } + ) + .padding() +} diff --git a/MovieBooking/Feature/MovieBook/MovieBookView.swift b/MovieBooking/Feature/MovieBook/MovieBookView.swift new file mode 100644 index 0000000..01f094c --- /dev/null +++ b/MovieBooking/Feature/MovieBook/MovieBookView.swift @@ -0,0 +1,60 @@ +// +// MovieBookView.swift +// MovieBooking +// +// Created by 홍석현 on 10/17/25. +// + +import SwiftUI + +struct MovieBookView: View { + let posterPath: String + let title: String + let pricePerTicket: Int = 13000 + + @State private var selectedTheater: String = "CGV 강남" + @State private var selectedTime: String = "14:30" + @State private var numberOfPeople: Int = 1 + + private let theaters = ["CGV 강남", "메가박스 코엑스", "롯데시네마 월드타워", "CGV 용산"] + private let times = ["10:00", "12:30", "14:30", "17:00", "19:30", "22:00"] + + var body: some View { + ScrollView { + VStack(spacing: 24) { + // 극장/시간/인원 선택 카드 + BookingOptionsCard( + theaters: theaters, + times: times, + selectedTheater: $selectedTheater, + selectedTime: $selectedTime, + numberOfPeople: $numberOfPeople + ) + // 결제 정보 카드 + PaymentInfoCard( + posterPath: posterPath, + title: title, + pricePerTicket: pricePerTicket, + numberOfPeople: numberOfPeople, + onPaymentTapped: handlePayment + ) + } + .padding(24) + } + } + + private func handlePayment() { + print("결제하기 버튼 눌림") + print("극장: \(selectedTheater)") + print("시간: \(selectedTime)") + print("인원: \(numberOfPeople)명") + print("총액: ₩\((pricePerTicket * numberOfPeople).formatted())") + } +} + +#Preview { + MovieBookView( + posterPath: "/bUrReoZFLGti6ehkBW0xw8f12MT.jpg", + title: "The Dark Knight" + ) +} From 1a6f08798518aae6a3b737300f1277f51a7b3039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Sun, 19 Oct 2025 15:36:49 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat.=20=EC=98=81=ED=99=94=20=EA=B7=B9?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20=EA=B4=80=EB=9E=8C=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8A=94=20usecase=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MovieBooking/Domain/Entity/MovieTheater.swift | 13 +++ .../UseCase/FetchMovieTheatersUseCase.swift | 89 +++++++++++++++++++ .../UseCase/FetchMovieTimesUseCase.swift | 77 ++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 MovieBooking/Domain/Entity/MovieTheater.swift create mode 100644 MovieBooking/Domain/UseCase/FetchMovieTheatersUseCase.swift create mode 100644 MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift diff --git a/MovieBooking/Domain/Entity/MovieTheater.swift b/MovieBooking/Domain/Entity/MovieTheater.swift new file mode 100644 index 0000000..4effc84 --- /dev/null +++ b/MovieBooking/Domain/Entity/MovieTheater.swift @@ -0,0 +1,13 @@ +// +// MovieTheater.swift +// MovieBooking +// +// Created by 홍석현 on 10/18/25. +// + +import Foundation + +struct MovieTheater: Identifiable, Equatable { + let id: Int + let name: String +} diff --git a/MovieBooking/Domain/UseCase/FetchMovieTheatersUseCase.swift b/MovieBooking/Domain/UseCase/FetchMovieTheatersUseCase.swift new file mode 100644 index 0000000..1ae72fd --- /dev/null +++ b/MovieBooking/Domain/UseCase/FetchMovieTheatersUseCase.swift @@ -0,0 +1,89 @@ +// +// FetchMovieTheatersUseCase.swift +// MovieBooking +// +// Created by 홍석현 on 10/18/25. +// + +import Foundation +import Dependencies + +protocol FetchMovieTheatersUseCaseProtocol { + func execute(_ movieId: String) async throws -> [MovieTheater] +} + +struct FetchMovieTheatersUseCase: FetchMovieTheatersUseCaseProtocol { + func execute(_ movieId: String) async throws -> [MovieTheater] { + try await Task.sleep(nanoseconds: 500_000_000) // 0.5초 딜레이 + + let allTheaters = [ + MovieTheater(id: 1, name: "CGV 강남"), + MovieTheater(id: 2, name: "CGV 용산아이파크몰"), + MovieTheater(id: 3, name: "CGV 명동역 씨네라이브러리"), + MovieTheater(id: 4, name: "CGV 여의도"), + MovieTheater(id: 5, name: "CGV 왕십리"), + MovieTheater(id: 6, name: "CGV 건대입구"), + MovieTheater(id: 7, name: "CGV 강변"), + MovieTheater(id: 8, name: "CGV 목동"), + MovieTheater(id: 9, name: "CGV 천호"), + MovieTheater(id: 10, name: "CGV 송파"), + MovieTheater(id: 11, name: "CGV 잠실"), + MovieTheater(id: 12, name: "CGV 구로"), + MovieTheater(id: 13, name: "롯데시네마 월드타워"), + MovieTheater(id: 14, name: "롯데시네마 홍대입구"), + MovieTheater(id: 15, name: "롯데시네마 건대입구"), + MovieTheater(id: 16, name: "롯데시네마 김포공항"), + MovieTheater(id: 17, name: "롯데시네마 가산디지털"), + MovieTheater(id: 18, name: "롯데시네마 영등포"), + MovieTheater(id: 19, name: "롯데시네마 노원"), + MovieTheater(id: 20, name: "롯데시네마 수락산"), + MovieTheater(id: 21, name: "롯데시네마 서울대입구"), + MovieTheater(id: 22, name: "롯데시네마 합정"), + MovieTheater(id: 23, name: "롯데시네마 청량리"), + MovieTheater(id: 24, name: "롯데시네마 동대문"), + MovieTheater(id: 25, name: "메가박스 코엑스"), + MovieTheater(id: 26, name: "메가박스 강남"), + MovieTheater(id: 27, name: "메가박스 이수"), + MovieTheater(id: 28, name: "메가박스 상봉"), + MovieTheater(id: 29, name: "메가박스 센트럴"), + MovieTheater(id: 30, name: "메가박스 신촌"), + MovieTheater(id: 31, name: "메가박스 목동"), + MovieTheater(id: 32, name: "메가박스 성수"), + MovieTheater(id: 33, name: "메가박스 동대문"), + MovieTheater(id: 34, name: "메가박스 군자"), + MovieTheater(id: 35, name: "메가박스 은평"), + MovieTheater(id: 36, name: "메가박스 화곡"), + MovieTheater(id: 37, name: "CGV 압구정"), + MovieTheater(id: 38, name: "CGV 청담씨네시티"), + MovieTheater(id: 39, name: "CGV 대학로"), + MovieTheater(id: 40, name: "CGV 피카디리1958"), + MovieTheater(id: 41, name: "CGV 용산"), + MovieTheater(id: 42, name: "CGV 서면삼정타워"), + MovieTheater(id: 43, name: "CGV 센텀시티"), + MovieTheater(id: 44, name: "CGV 해운대"), + MovieTheater(id: 45, name: "CGV 대연"), + MovieTheater(id: 46, name: "롯데시네마 부산본점"), + MovieTheater(id: 47, name: "롯데시네마 사상"), + MovieTheater(id: 48, name: "롯데시네마 오투"), + MovieTheater(id: 49, name: "메가박스 해운대"), + MovieTheater(id: 50, name: "메가박스 장산"), + ] + + // 랜덤하게 15~30개 극장 선택 + let randomCount = Int.random(in: 15...30) + return allTheaters.shuffled().prefix(randomCount).sorted { $0.id < $1.id } + } +} + +private enum FetchMovieTheatersUseCaseKey: DependencyKey { + static let liveValue: FetchMovieTheatersUseCaseProtocol = FetchMovieTheatersUseCase() + static let previewValue: any FetchMovieTheatersUseCaseProtocol = FetchMovieTheatersUseCase() + static let testValue: any FetchMovieTheatersUseCaseProtocol = FetchMovieTheatersUseCase() +} + +extension DependencyValues { + var fetchMovieTheatersUseCase: FetchMovieTheatersUseCaseProtocol { + get { self[FetchMovieTheatersUseCaseKey.self] } + set { self[FetchMovieTheatersUseCaseKey.self] = newValue } + } +} diff --git a/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift b/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift new file mode 100644 index 0000000..b11cc3b --- /dev/null +++ b/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift @@ -0,0 +1,77 @@ +// +// FetchMovieTimesUseCase.swift +// MovieBooking +// +// Created by 홍석현 on 10/18/25. +// + +import Foundation +import Dependencies + +protocol FetchMovieTimesUseCaseProtocol { + func execute( + _ movieId: String, + at theaterId: Int + ) async throws -> [String] +} + +struct FetchMovieTimesUseCase: FetchMovieTimesUseCaseProtocol { + func execute( + _ movieId: String, + at theaterId: Int + ) async throws -> [String] { + try await Task.sleep(nanoseconds: 300_000_000) // 0.3초 딜레이 + + // movieId와 theaterId를 시드로 사용하여 일관된 랜덤 결과 생성 + let seed = (movieId.hashValue &+ theaterId) & 0x7FFFFFFF + var generator = SeededRandomGenerator(seed: UInt64(seed)) + + // 가능한 상영 시간대 (30분 간격) + let possibleTimes = [ + "09:00", "09:30", "10:00", "10:30", "11:00", "11:30", + "12:00", "12:30", "13:00", "13:30", "14:00", "14:30", + "15:00", "15:30", "16:00", "16:30", "17:00", "17:30", + "18:00", "18:30", "19:00", "19:30", "20:00", "20:30", + "21:00", "21:30", "22:00", "22:30", "23:00", "23:30" + ] + + // 랜덤하게 4~8개의 상영 시간 선택 + let timeCount = Int(generator.next() % 5) + 4 // 4~8 + var selectedIndices = Set() + + while selectedIndices.count < timeCount { + let index = Int(generator.next() % UInt64(possibleTimes.count)) + selectedIndices.insert(index) + } + + return selectedIndices.sorted().map { possibleTimes[$0] } + } +} + +// 시드 기반 랜덤 생성기 (동일한 movieId + theaterId 조합에 대해 일관된 결과 제공) +private struct SeededRandomGenerator { + private var state: UInt64 + + init(seed: UInt64) { + self.state = seed == 0 ? 1 : seed + } + + mutating func next() -> UInt64 { + // LCG (Linear Congruential Generator) -> 자세히는 모르겠음 + state = (state &* 6364136223846793005) &+ 1442695040888963407 + return state + } +} + +private enum FetchMovieTimesUseCaseKey: DependencyKey { + static let liveValue: FetchMovieTimesUseCaseProtocol = FetchMovieTimesUseCase() + static let previewValue: any FetchMovieTimesUseCaseProtocol = FetchMovieTimesUseCase() + static let testValue: any FetchMovieTimesUseCaseProtocol = FetchMovieTimesUseCase() +} + +extension DependencyValues { + var fetchMovieTimesUseCase: FetchMovieTimesUseCaseProtocol { + get { self[FetchMovieTimesUseCaseKey.self] } + set { self[FetchMovieTimesUseCaseKey.self] = newValue } + } +} From 814402028258a499dda8a2b81365e04a8c477f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Sun, 19 Oct 2025 15:37:23 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat=20MovieBookFeature=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20SelectionListView=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=B0=8F=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Components/BookingOptionsCard.swift | 105 ++++------- .../Components/Extension+Identifiable.swift | 16 ++ .../Components/SelectionListView.swift | 121 +++++++++++++ .../Feature/MovieBook/MovieBookFeature.swift | 166 ++++++++++++++++++ .../Feature/MovieBook/MovieBookView.swift | 83 +++++---- 5 files changed, 374 insertions(+), 117 deletions(-) create mode 100644 MovieBooking/Feature/MovieBook/Components/Extension+Identifiable.swift create mode 100644 MovieBooking/Feature/MovieBook/Components/SelectionListView.swift create mode 100644 MovieBooking/Feature/MovieBook/MovieBookFeature.swift diff --git a/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift index e79da42..117de22 100644 --- a/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift +++ b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift @@ -8,34 +8,43 @@ import SwiftUI struct BookingOptionsCard: View { - let theaters: [String] + let theaters: [MovieTheater] let times: [String] - @Binding var selectedTheater: String - @Binding var selectedTime: String + @Binding var selectedTheater: MovieTheater? + @Binding var selectedTime: String? @Binding var numberOfPeople: Int var body: some View { VStack(spacing: 20) { // 극장 선택 - CustomPickerRow( + SelectionListView( title: "극장", - selection: $selectedTheater, - options: theaters + items: theaters, + selectedItem: selectedTheater, + onSelect: { selectedTheater = $0 }, + placeholder: "극장을 선택해주세요", + displayText: { $0.name } ) // 상영시간 선택 - CustomPickerRow( + SelectionListView( title: "상영시간", - selection: $selectedTime, - options: times + items: times, + selectedItem: selectedTime, + onSelect: { selectedTime = $0 }, + placeholder: "상영시간을 선택해주세요", + displayText: { $0 } ) + .disabled(selectedTheater == nil) + .opacity(selectedTheater == nil ? 0.5 : 1.0) // 인원수 선택 - CustomPickerRow( + SelectionListView( title: "인원수", - selection: $numberOfPeople, - options: Array(1...10), - displayFormatter: { "\($0)명" } + items: Array(1...10), + selectedItem: numberOfPeople, + onSelect: { numberOfPeople = $0 }, + displayText: { "\($0)명" } ) } .padding(20) @@ -45,76 +54,24 @@ struct BookingOptionsCard: View { } } -// MARK: - Custom Picker Row -fileprivate struct CustomPickerRow: View { - let title: String - @Binding var selection: T - let options: [T] - var displayFormatter: ((T) -> String)? - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(title) - .font(.system(size: 16, weight: .semibold)) - - Menu { - ForEach(options, id: \.self) { option in - Button { - selection = option - } label: { - HStack { - Text(displayText(for: option)) - if selection == option { - Spacer() - Image(systemName: "checkmark") - .foregroundColor(.basicPurple) - } - } - } - } - } label: { - HStack { - Text(displayText(for: selection)) - .foregroundColor(.primary) - .font(.system(size: 16)) - - Spacer() - - Image(systemName: "chevron.down") - .font(.system(size: 14)) - .foregroundColor(.secondary) - } - .padding(14) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 14)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - } - } - } - - private func displayText(for option: T) -> String { - if let formatter = displayFormatter { - return formatter(option) - } - return "\(option)" - } -} - +// MARK: - Preview #Preview { BookingOptionsCardPreview() } private struct BookingOptionsCardPreview: View { - @State var selectedTheater = "CGV 강남" - @State var selectedTime = "14:30" + @State var selectedTheater: MovieTheater? = MovieTheater(id: 1, name: "CGV 강남") + @State var selectedTime: String? = "14:30" @State var numberOfPeople = 1 var body: some View { BookingOptionsCard( - theaters: ["CGV 강남", "메가박스 코엑스", "롯데시네마 월드타워", "CGV 용산"], + theaters: [ + MovieTheater(id: 1, name: "CGV 강남"), + MovieTheater(id: 2, name: "메가박스 코엑스"), + MovieTheater(id: 3, name: "롯데시네마 월드타워"), + MovieTheater(id: 4, name: "CGV 용산") + ], times: ["10:00", "12:30", "14:30", "17:00", "19:30", "22:00"], selectedTheater: $selectedTheater, selectedTime: $selectedTime, diff --git a/MovieBooking/Feature/MovieBook/Components/Extension+Identifiable.swift b/MovieBooking/Feature/MovieBook/Components/Extension+Identifiable.swift new file mode 100644 index 0000000..000c7e9 --- /dev/null +++ b/MovieBooking/Feature/MovieBook/Components/Extension+Identifiable.swift @@ -0,0 +1,16 @@ +// +// Extension+Identifiable.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation + +extension String: Identifiable { + public var id: String { self } +} + +extension Int: Identifiable { + public var id: Int { self } +} diff --git a/MovieBooking/Feature/MovieBook/Components/SelectionListView.swift b/MovieBooking/Feature/MovieBook/Components/SelectionListView.swift new file mode 100644 index 0000000..314593b --- /dev/null +++ b/MovieBooking/Feature/MovieBook/Components/SelectionListView.swift @@ -0,0 +1,121 @@ +// +// SelectionListView.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import SwiftUI + +struct SelectionListView: View where Item.ID: Equatable { + let title: String + let items: [Item] + let selectedItem: Item? + let onSelect: (Item) -> Void + let placeholder: String? + let displayText: (Item) -> String + + init( + title: String, + items: [Item], + selectedItem: Item?, + onSelect: @escaping (Item) -> Void, + placeholder: String? = nil, + displayText: @escaping (Item) -> String, + ) { + self.title = title + self.items = items + self.selectedItem = selectedItem + self.onSelect = onSelect + self.displayText = displayText + self.placeholder = placeholder + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + + selectionMenu + } + } + + private var selectionMenu: some View { + Menu { + ForEach(items) { item in + Button { + onSelect(item) + } label: { + HStack { + Text(displayText(item)) + if selectedItem?.id == item.id { + Spacer() + Image(systemName: "checkmark") + .foregroundColor(.basicPurple) + } + } + } + } + } label: { + HStack { + if let selected = selectedItem { + Text(displayText(selected)) + .foregroundColor(.primary) + .font(.system(size: 16)) + } else { + Text(placeholder ?? "선택해주세요") + .foregroundColor(.secondary) + .font(.system(size: 16)) + } + + Spacer() + + Image(systemName: "chevron.down") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .padding(14) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + } + } +} + +// MARK: - Preview Support +#Preview { + SelectionListViewPreview() +} + +private struct PreviewItem: Identifiable, Hashable { + let id: Int + let name: String +} + +private struct SelectionListViewPreview: View { + @State var selectedItem: PreviewItem? = nil + + let items = [ + PreviewItem(id: 1, name: "CGV 강남"), + PreviewItem(id: 2, name: "메가박스 코엑스"), + PreviewItem(id: 3, name: "롯데시네마 월드타워"), + PreviewItem(id: 4, name: "CGV 용산") + ] + + var body: some View { + VStack(spacing: 20) { + // 정상 상태 + SelectionListView( + title: "극장", + items: items, + selectedItem: selectedItem, + onSelect: { selectedItem = $0 }, + displayText: { $0.name } + ) + } + .padding() + } +} diff --git a/MovieBooking/Feature/MovieBook/MovieBookFeature.swift b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift new file mode 100644 index 0000000..3807be9 --- /dev/null +++ b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift @@ -0,0 +1,166 @@ +// +// MovieBookFeature.swift +// MovieBooking +// +// Created by 홍석현 on 10/18/25. +// + +import Foundation +import ComposableArchitecture + +@Reducer +struct MovieBookFeature { + @Dependency(\.fetchMovieTimesUseCase) var fetchMovieTimesUseCase + @Dependency(\.fetchMovieTheatersUseCase) var fetchMovieTheatersUseCase + + @ObservableState + struct State { + let movieId: String + let posterPath: String + let title: String + var theaters: [MovieTheater] = [] + var times: [String] = [] + var selectedTheater: MovieTheater? + var selectedTime: String? + var numberOfPeople: Int = 1 + let pricePerTicket: Int = 13000 + } + + enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(ViewAction) + case async(AsyncAction) + case inner(InnerAction) + + enum ViewAction { + case onAppear + case theaterSelected(MovieTheater) + case timeSelected(String) + case numberOfPeopleChanged(Int) + case onTapBookButton + } + + enum AsyncAction { + case fetchTheatersResponse(Result<[MovieTheater], Error>) + case fetchTimesResponse(Result<[String], Error>) + } + + enum InnerAction { + case fetchTheaters + case fetchTimes(theaterId: Int) + } + } + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.numberOfPeople): + return .send(.view(.numberOfPeopleChanged(state.numberOfPeople))) + case .binding(\.selectedTime): + guard let selectedTime = state.selectedTime else { return .none } + return .send(.view(.timeSelected(selectedTime))) + case .binding(\.selectedTheater): + guard let selectedTheater = state.selectedTheater else { return .none } + return .send(.view(.theaterSelected(selectedTheater))) + case .binding: + return .none + case .view(let viewAction): + return handleViewAction(&state, viewAction) + case .async(let asyncAction): + return handleAsyncAction(&state, asyncAction) + case .inner(let innerAction): + return handleInnerAction(&state, innerAction) + } + } + } +} + +extension MovieBookFeature { + func handleViewAction( + _ state: inout State, + _ action: Action.ViewAction + ) -> Effect { + switch action { + case .onAppear: + return .send(.inner(.fetchTheaters)) + + case .theaterSelected(let theater): + state.selectedTheater = theater + state.selectedTime = nil + state.times = [] + return .send(.inner(.fetchTimes(theaterId: theater.id))) + + case .timeSelected(let time): + state.selectedTime = time + print(time) + return .none + + case .numberOfPeopleChanged(let count): + state.numberOfPeople = max(1, count) + return .none + + case .onTapBookButton: + print("결제하기 버튼 눌림") + print("극장: \(state.selectedTheater?.name ?? "선택 안 됨")") + print("시간: \(state.selectedTime ?? "선택 안 됨")") + print("인원: \(state.numberOfPeople)명") + print("총액: ₩\((state.pricePerTicket * state.numberOfPeople).formatted())") + return .none + } + } + + func handleAsyncAction( + _ state: inout State, + _ action: Action.AsyncAction + ) -> Effect { + switch action { + case .fetchTheatersResponse(.success(let theaters)): + state.theaters = theaters + return .none + + case .fetchTheatersResponse(.failure): + return .none + + case .fetchTimesResponse(.success(let times)): + state.times = times + return .none + + case .fetchTimesResponse(.failure): + return .none + } + } + + func handleInnerAction( + _ state: inout State, + _ action: Action.InnerAction + ) -> Effect { + switch action { + case .fetchTheaters: + return .run { [movieId = state.movieId] send in + await send( + .async( + .fetchTheatersResponse( + Result { + try await fetchMovieTheatersUseCase.execute(movieId) + } + ) + ) + ) + } + + case .fetchTimes(let theaterId): + return .run { [movieId = state.movieId] send in + await send( + .async( + .fetchTimesResponse( + Result { + try await fetchMovieTimesUseCase.execute(movieId, at: theaterId) + } + ) + ) + ) + } + } + } +} diff --git a/MovieBooking/Feature/MovieBook/MovieBookView.swift b/MovieBooking/Feature/MovieBook/MovieBookView.swift index 01f094c..62e4237 100644 --- a/MovieBooking/Feature/MovieBook/MovieBookView.swift +++ b/MovieBooking/Feature/MovieBook/MovieBookView.swift @@ -6,55 +6,52 @@ // import SwiftUI +import ComposableArchitecture +@ViewAction(for: MovieBookFeature.self) struct MovieBookView: View { - let posterPath: String - let title: String - let pricePerTicket: Int = 13000 - - @State private var selectedTheater: String = "CGV 강남" - @State private var selectedTime: String = "14:30" - @State private var numberOfPeople: Int = 1 - - private let theaters = ["CGV 강남", "메가박스 코엑스", "롯데시네마 월드타워", "CGV 용산"] - private let times = ["10:00", "12:30", "14:30", "17:00", "19:30", "22:00"] - - var body: some View { - ScrollView { - VStack(spacing: 24) { - // 극장/시간/인원 선택 카드 - BookingOptionsCard( - theaters: theaters, - times: times, - selectedTheater: $selectedTheater, - selectedTime: $selectedTime, - numberOfPeople: $numberOfPeople - ) - // 결제 정보 카드 - PaymentInfoCard( - posterPath: posterPath, - title: title, - pricePerTicket: pricePerTicket, - numberOfPeople: numberOfPeople, - onPaymentTapped: handlePayment - ) - } - .padding(24) + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + ScrollView { + VStack(spacing: 24) { + // 극장/시간/인원 선택 카드 + BookingOptionsCard( + theaters: store.theaters, + times: store.times, + selectedTheater: $store.selectedTheater, + selectedTime: $store.selectedTime, + numberOfPeople: $store.numberOfPeople + ) + // 결제 정보 카드 + PaymentInfoCard( + posterPath: store.posterPath, + title: store.title, + pricePerTicket: store.pricePerTicket, + numberOfPeople: store.numberOfPeople, + onPaymentTapped: { send(.onTapBookButton) } + ) } + .padding(24) + } + .onAppear { + send(.onAppear) + } } - - private func handlePayment() { - print("결제하기 버튼 눌림") - print("극장: \(selectedTheater)") - print("시간: \(selectedTime)") - print("인원: \(numberOfPeople)명") - print("총액: ₩\((pricePerTicket * numberOfPeople).formatted())") - } + } } #Preview { MovieBookView( - posterPath: "/bUrReoZFLGti6ehkBW0xw8f12MT.jpg", - title: "The Dark Knight" - ) + store: Store( + initialState: MovieBookFeature.State( + movieId: "12345", + posterPath: "/bUrReoZFLGti6ehkBW0xw8f12MT.jpg", + title: "The Dark Knight" + ) + ) { + MovieBookFeature() + } + ) } From fc49b8034ef966974f2f69ccb952e9ef1366cbdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Sun, 19 Oct 2025 16:15:13 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat.=20=EC=98=88=EB=A7=A4=20=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20usecase=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/DataSources/BookingDataSource.swift | 52 +++++++++ .../Data/Repository/BookingRepository.swift | 28 +++++ MovieBooking/Domain/Entity/BookingInfo.swift | 45 ++++++++ .../BookingRepositoryProtocol.swift | 28 +++++ .../Domain/UseCase/CreateBookingUseCase.swift | 38 +++++++ .../Feature/MovieBook/MovieBookFeature.swift | 101 ++++++++++++++++-- .../Feature/MovieBook/MovieBookView.swift | 12 +++ 7 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 MovieBooking/Data/DataSources/BookingDataSource.swift create mode 100644 MovieBooking/Data/Repository/BookingRepository.swift create mode 100644 MovieBooking/Domain/Entity/BookingInfo.swift create mode 100644 MovieBooking/Domain/Repository/BookingRepositoryProtocol.swift create mode 100644 MovieBooking/Domain/UseCase/CreateBookingUseCase.swift diff --git a/MovieBooking/Data/DataSources/BookingDataSource.swift b/MovieBooking/Data/DataSources/BookingDataSource.swift new file mode 100644 index 0000000..0daf10a --- /dev/null +++ b/MovieBooking/Data/DataSources/BookingDataSource.swift @@ -0,0 +1,52 @@ +// +// BookingDataSource.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation + +protocol BookingDataSourceProtocol { + func saveBooking(_ bookingInfo: BookingInfo) async throws -> BookingInfo + func fetchAllBookings() async throws -> [BookingInfo] + func deleteBooking(id: String) async throws +} + +final class LocalBookingDataSource: BookingDataSourceProtocol { + private let userDefaults: UserDefaults + private let bookingsKey = "com.moviebooking.bookings" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + func saveBooking(_ bookingInfo: BookingInfo) async throws -> BookingInfo { + var bookings = try await fetchAllBookings() + bookings.append(bookingInfo) + + let encoder = JSONEncoder() + let data = try encoder.encode(bookings) + userDefaults.set(data, forKey: bookingsKey) + + return bookingInfo + } + + func fetchAllBookings() async throws -> [BookingInfo] { + guard let data = userDefaults.data(forKey: bookingsKey) else { + return [] + } + + let decoder = JSONDecoder() + return try decoder.decode([BookingInfo].self, from: data) + } + + func deleteBooking(id: String) async throws { + var bookings = try await fetchAllBookings() + bookings.removeAll { $0.id == id } + + let encoder = JSONEncoder() + let data = try encoder.encode(bookings) + userDefaults.set(data, forKey: bookingsKey) + } +} diff --git a/MovieBooking/Data/Repository/BookingRepository.swift b/MovieBooking/Data/Repository/BookingRepository.swift new file mode 100644 index 0000000..b92bc9e --- /dev/null +++ b/MovieBooking/Data/Repository/BookingRepository.swift @@ -0,0 +1,28 @@ +// +// BookingRepository.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation + +struct BookingRepository: BookingRepositoryProtocol { + private let dataSource: BookingDataSourceProtocol + + init(dataSource: BookingDataSourceProtocol = LocalBookingDataSource()) { + self.dataSource = dataSource + } + + func createBooking(_ bookingInfo: BookingInfo) async throws -> BookingInfo { + try await dataSource.saveBooking(bookingInfo) + } + + func fetchBookings() async throws -> [BookingInfo] { + try await dataSource.fetchAllBookings() + } + + func deleteBooking(id: String) async throws { + try await dataSource.deleteBooking(id: id) + } +} diff --git a/MovieBooking/Domain/Entity/BookingInfo.swift b/MovieBooking/Domain/Entity/BookingInfo.swift new file mode 100644 index 0000000..91fcbc1 --- /dev/null +++ b/MovieBooking/Domain/Entity/BookingInfo.swift @@ -0,0 +1,45 @@ +// +// BookingInfo.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation + +struct BookingInfo: Identifiable, Codable, Equatable { + let id: String + let movieId: String + let movieTitle: String + let posterPath: String + let theaterId: Int + let theaterName: String + let showTime: String + let numberOfPeople: Int + let totalPrice: Int + let bookedAt: Date + + init( + id: String = UUID().uuidString, + movieId: String, + movieTitle: String, + posterPath: String, + theaterId: Int, + theaterName: String, + showTime: String, + numberOfPeople: Int, + totalPrice: Int, + bookedAt: Date = Date() + ) { + self.id = id + self.movieId = movieId + self.movieTitle = movieTitle + self.posterPath = posterPath + self.theaterId = theaterId + self.theaterName = theaterName + self.showTime = showTime + self.numberOfPeople = numberOfPeople + self.totalPrice = totalPrice + self.bookedAt = bookedAt + } +} diff --git a/MovieBooking/Domain/Repository/BookingRepositoryProtocol.swift b/MovieBooking/Domain/Repository/BookingRepositoryProtocol.swift new file mode 100644 index 0000000..b37a4dd --- /dev/null +++ b/MovieBooking/Domain/Repository/BookingRepositoryProtocol.swift @@ -0,0 +1,28 @@ +// +// BookingRepositoryProtocol.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation +import Dependencies + +protocol BookingRepositoryProtocol { + func createBooking(_ bookingInfo: BookingInfo) async throws -> BookingInfo + func fetchBookings() async throws -> [BookingInfo] + func deleteBooking(id: String) async throws +} + +private enum BookingRepositoryKey: DependencyKey { + static let liveValue: any BookingRepositoryProtocol = BookingRepository() + static let previewValue: any BookingRepositoryProtocol = BookingRepository() + static let testValue: any BookingRepositoryProtocol = BookingRepository() +} + +extension DependencyValues { + var bookingRepository: BookingRepositoryProtocol { + get { self[BookingRepositoryKey.self] } + set { self[BookingRepositoryKey.self] = newValue } + } +} diff --git a/MovieBooking/Domain/UseCase/CreateBookingUseCase.swift b/MovieBooking/Domain/UseCase/CreateBookingUseCase.swift new file mode 100644 index 0000000..9fe20ac --- /dev/null +++ b/MovieBooking/Domain/UseCase/CreateBookingUseCase.swift @@ -0,0 +1,38 @@ +// +// CreateBookingUseCase.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation +import Dependencies + +protocol CreateBookingUseCaseProtocol { + func execute(_ bookingInfo: BookingInfo) async throws -> BookingInfo +} + +struct CreateBookingUseCase: CreateBookingUseCaseProtocol { + @Dependency(\.bookingRepository) var repository + + func execute(_ bookingInfo: BookingInfo) async throws -> BookingInfo { + // 간단한 딜레이로 네트워크 호출 시뮬레이션 + try await Task.sleep(nanoseconds: 500_000_000) // 0.5초 + + // Repository를 통해 저장 + return try await repository.createBooking(bookingInfo) + } +} + +private enum CreateBookingUseCaseKey: DependencyKey { + static let liveValue: CreateBookingUseCaseProtocol = CreateBookingUseCase() + static let previewValue: CreateBookingUseCaseProtocol = CreateBookingUseCase() + static let testValue: CreateBookingUseCaseProtocol = CreateBookingUseCase() +} + +extension DependencyValues { + var createBookingUseCase: CreateBookingUseCaseProtocol { + get { self[CreateBookingUseCaseKey.self] } + set { self[CreateBookingUseCaseKey.self] = newValue } + } +} diff --git a/MovieBooking/Feature/MovieBook/MovieBookFeature.swift b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift index 3807be9..ef87dff 100644 --- a/MovieBooking/Feature/MovieBook/MovieBookFeature.swift +++ b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift @@ -7,11 +7,13 @@ import Foundation import ComposableArchitecture +internal import SwiftUICore @Reducer struct MovieBookFeature { @Dependency(\.fetchMovieTimesUseCase) var fetchMovieTimesUseCase @Dependency(\.fetchMovieTheatersUseCase) var fetchMovieTheatersUseCase + @Dependency(\.createBookingUseCase) var createBookingUseCase @ObservableState struct State { @@ -24,6 +26,8 @@ struct MovieBookFeature { var selectedTime: String? var numberOfPeople: Int = 1 let pricePerTicket: Int = 13000 + var isBookingInProgress: Bool = false + @Presents var alert: AlertState? } enum Action: ViewAction, BindableAction { @@ -31,6 +35,7 @@ struct MovieBookFeature { case view(ViewAction) case async(AsyncAction) case inner(InnerAction) + case alert(PresentationAction) enum ViewAction { case onAppear @@ -43,11 +48,17 @@ struct MovieBookFeature { enum AsyncAction { case fetchTheatersResponse(Result<[MovieTheater], Error>) case fetchTimesResponse(Result<[String], Error>) + case createBookingResponse(Result) } enum InnerAction { case fetchTheaters case fetchTimes(theaterId: Int) + case createBooking + } + + enum Alert { + case confirmBookingSuccess } } @@ -71,8 +82,11 @@ struct MovieBookFeature { return handleAsyncAction(&state, asyncAction) case .inner(let innerAction): return handleInnerAction(&state, innerAction) + case .alert: + return .none } } + .ifLet(\.$alert, action: \.alert) } } @@ -101,12 +115,22 @@ extension MovieBookFeature { return .none case .onTapBookButton: - print("결제하기 버튼 눌림") - print("극장: \(state.selectedTheater?.name ?? "선택 안 됨")") - print("시간: \(state.selectedTime ?? "선택 안 됨")") - print("인원: \(state.numberOfPeople)명") - print("총액: ₩\((state.pricePerTicket * state.numberOfPeople).formatted())") - return .none + // 유효성 검사 + guard state.selectedTheater != nil, + state.selectedTime != nil else { + state.alert = AlertState(title: { + TextState("예매 오류") + }, actions: { + ButtonState(role: .cancel) { + TextState("확인") + } + }, message: { + TextState("극장과 상영시간을 모두 선택해주세요.") + }) + return .none + } + + return .send(.inner(.createBooking)) } } @@ -128,6 +152,40 @@ extension MovieBookFeature { case .fetchTimesResponse(.failure): return .none + + case .createBookingResponse(.success(let booking)): + state.isBookingInProgress = false + print("✅ 예매 성공!") + print("예매 ID: \(booking.id)") + print("영화: \(booking.movieTitle)") + print("극장: \(booking.theaterName)") + print("시간: \(booking.showTime)") + print("인원: \(booking.numberOfPeople)명") + print("총액: ₩\(booking.totalPrice.formatted())") + + state.alert = AlertState { + TextState("예매 완료") + } actions: { + ButtonState(action: .confirmBookingSuccess) { + TextState("확인") + } + } message: { + TextState("영화 예매가 성공적으로 완료되었습니다!") + } + return .none + + case .createBookingResponse(.failure(let error)): + state.isBookingInProgress = false + state.alert = AlertState { + TextState("예매 오류") + } actions: { + ButtonState(role: .cancel) { + TextState("확인") + } + } message: { + TextState("예매에 실패했습니다: \(error.localizedDescription)") + } + return .none } } @@ -161,6 +219,37 @@ extension MovieBookFeature { ) ) } + + case .createBooking: + guard let theater = state.selectedTheater, + let time = state.selectedTime else { + return .none + } + + state.isBookingInProgress = true + + let bookingInfo = BookingInfo( + movieId: state.movieId, + movieTitle: state.title, + posterPath: state.posterPath, + theaterId: theater.id, + theaterName: theater.name, + showTime: time, + numberOfPeople: state.numberOfPeople, + totalPrice: state.pricePerTicket * state.numberOfPeople + ) + + return .run { send in + await send( + .async( + .createBookingResponse( + Result { + try await createBookingUseCase.execute(bookingInfo) + } + ) + ) + ) + } } } } diff --git a/MovieBooking/Feature/MovieBook/MovieBookView.swift b/MovieBooking/Feature/MovieBook/MovieBookView.swift index 62e4237..7d831dd 100644 --- a/MovieBooking/Feature/MovieBook/MovieBookView.swift +++ b/MovieBooking/Feature/MovieBook/MovieBookView.swift @@ -32,12 +32,24 @@ struct MovieBookView: View { numberOfPeople: store.numberOfPeople, onPaymentTapped: { send(.onTapBookButton) } ) + .disabled(store.isBookingInProgress) + .opacity(store.isBookingInProgress ? 0.6 : 1.0) } .padding(24) } .onAppear { send(.onAppear) } + .alert($store.scope(state: \.alert, action: \.alert)) + .overlay { + if store.isBookingInProgress { + ProgressView("예매 중...") + .padding() + .background(Color.white) + .cornerRadius(10) + .shadow(radius: 10) + } + } } } } From 58e84f88d44daa97e567f845b1d55e2403478b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Sun, 19 Oct 2025 16:51:20 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat.=20=EC=83=81=EC=98=81=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=8B=9C=EA=B0=84=20=EB=BF=90=EC=95=84=EB=8B=88?= =?UTF-8?q?=EB=9D=BC=20=EB=82=A0=EC=A7=9C=20=EC=A0=95=EB=B3=B4=EB=8F=84=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MovieBooking/Domain/Entity/BookingInfo.swift | 20 ++++++- MovieBooking/Domain/Entity/ShowTime.swift | 54 +++++++++++++++++++ .../UseCase/FetchMovieTimesUseCase.swift | 13 +++-- .../Components/BookingOptionsCard.swift | 25 +++++---- .../Feature/MovieBook/MovieBookFeature.swift | 33 ++++++------ .../Feature/MovieBook/MovieBookView.swift | 4 +- 6 files changed, 117 insertions(+), 32 deletions(-) create mode 100644 MovieBooking/Domain/Entity/ShowTime.swift diff --git a/MovieBooking/Domain/Entity/BookingInfo.swift b/MovieBooking/Domain/Entity/BookingInfo.swift index 91fcbc1..68feeac 100644 --- a/MovieBooking/Domain/Entity/BookingInfo.swift +++ b/MovieBooking/Domain/Entity/BookingInfo.swift @@ -14,10 +14,11 @@ struct BookingInfo: Identifiable, Codable, Equatable { let posterPath: String let theaterId: Int let theaterName: String - let showTime: String + let showDate: Date // 상영 날짜 + let showTime: String // 상영 시간 (예: "14:30") let numberOfPeople: Int let totalPrice: Int - let bookedAt: Date + let bookedAt: Date // 예매한 날짜 init( id: String = UUID().uuidString, @@ -26,6 +27,7 @@ struct BookingInfo: Identifiable, Codable, Equatable { posterPath: String, theaterId: Int, theaterName: String, + showDate: Date, showTime: String, numberOfPeople: Int, totalPrice: Int, @@ -37,9 +39,23 @@ struct BookingInfo: Identifiable, Codable, Equatable { self.posterPath = posterPath self.theaterId = theaterId self.theaterName = theaterName + self.showDate = showDate self.showTime = showTime self.numberOfPeople = numberOfPeople self.totalPrice = totalPrice self.bookedAt = bookedAt } + + /// 표시용 상영 날짜 (예: "2025년 10월 20일") + var displayShowDate: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 M월 d일 (E)" + return formatter.string(from: showDate) + } + + /// 전체 상영 정보 (예: "2025년 10월 20일 14:30") + var fullShowDateTime: String { + "\(displayShowDate) \(showTime)" + } } diff --git a/MovieBooking/Domain/Entity/ShowTime.swift b/MovieBooking/Domain/Entity/ShowTime.swift new file mode 100644 index 0000000..4febf78 --- /dev/null +++ b/MovieBooking/Domain/Entity/ShowTime.swift @@ -0,0 +1,54 @@ +// +// ShowTime.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation + +struct ShowTime: Identifiable, Codable, Equatable { + let id: String + let date: Date + let time: String + + init(id: String = UUID().uuidString, date: Date, time: String) { + self.id = id + self.date = date + self.time = time + } + + /// 표시용 날짜 문자열 (예: "2025년 10월 20일") + var displayDate: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "yyyy년 M월 d일" + return formatter.string(from: date) + } + + /// 표시용 요일 (예: "월요일") + var displayWeekday: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "EEEE" + return formatter.string(from: date) + } + + /// 표시용 짧은 날짜 (예: "10/20 (월)") + var displayShortDate: String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M/d" + let dateStr = formatter.string(from: date) + + formatter.dateFormat = "E" + let weekdayStr = formatter.string(from: date) + + return "\(dateStr) (\(weekdayStr))" + } + + /// 전체 표시 (예: "2025년 10월 20일 14:30") + var fullDisplay: String { + "\(displayDate) \(time)" + } +} diff --git a/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift b/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift index b11cc3b..3e96b6a 100644 --- a/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift +++ b/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift @@ -12,14 +12,14 @@ protocol FetchMovieTimesUseCaseProtocol { func execute( _ movieId: String, at theaterId: Int - ) async throws -> [String] + ) async throws -> [ShowTime] } struct FetchMovieTimesUseCase: FetchMovieTimesUseCaseProtocol { func execute( _ movieId: String, at theaterId: Int - ) async throws -> [String] { + ) async throws -> [ShowTime] { try await Task.sleep(nanoseconds: 300_000_000) // 0.3초 딜레이 // movieId와 theaterId를 시드로 사용하여 일관된 랜덤 결과 생성 @@ -35,6 +35,11 @@ struct FetchMovieTimesUseCase: FetchMovieTimesUseCaseProtocol { "21:00", "21:30", "22:00", "22:30", "23:00", "23:30" ] + // 향후 7일 중 랜덤 날짜 생성 (3~5일 사이) + let daysAhead = Int(generator.next() % 3) + 3 // 3~5일 후 + let calendar = Calendar.current + let showDate = calendar.date(byAdding: .day, value: daysAhead, to: Date())! + // 랜덤하게 4~8개의 상영 시간 선택 let timeCount = Int(generator.next() % 5) + 4 // 4~8 var selectedIndices = Set() @@ -44,7 +49,9 @@ struct FetchMovieTimesUseCase: FetchMovieTimesUseCaseProtocol { selectedIndices.insert(index) } - return selectedIndices.sorted().map { possibleTimes[$0] } + return selectedIndices.sorted().map { index in + ShowTime(date: showDate, time: possibleTimes[index]) + } } } diff --git a/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift index 117de22..dfb6f2b 100644 --- a/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift +++ b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift @@ -9,9 +9,9 @@ import SwiftUI struct BookingOptionsCard: View { let theaters: [MovieTheater] - let times: [String] + let showTimes: [ShowTime] @Binding var selectedTheater: MovieTheater? - @Binding var selectedTime: String? + @Binding var selectedShowTime: ShowTime? @Binding var numberOfPeople: Int var body: some View { @@ -29,11 +29,11 @@ struct BookingOptionsCard: View { // 상영시간 선택 SelectionListView( title: "상영시간", - items: times, - selectedItem: selectedTime, - onSelect: { selectedTime = $0 }, + items: showTimes, + selectedItem: selectedShowTime, + onSelect: { selectedShowTime = $0 }, placeholder: "상영시간을 선택해주세요", - displayText: { $0 } + displayText: { "\($0.displayShortDate) \($0.time)" } ) .disabled(selectedTheater == nil) .opacity(selectedTheater == nil ? 0.5 : 1.0) @@ -61,7 +61,7 @@ struct BookingOptionsCard: View { private struct BookingOptionsCardPreview: View { @State var selectedTheater: MovieTheater? = MovieTheater(id: 1, name: "CGV 강남") - @State var selectedTime: String? = "14:30" + @State var selectedShowTime: ShowTime? = ShowTime(date: Date(), time: "14:30") @State var numberOfPeople = 1 var body: some View { @@ -72,9 +72,16 @@ private struct BookingOptionsCardPreview: View { MovieTheater(id: 3, name: "롯데시네마 월드타워"), MovieTheater(id: 4, name: "CGV 용산") ], - times: ["10:00", "12:30", "14:30", "17:00", "19:30", "22:00"], + showTimes: [ + ShowTime(date: Date(), time: "10:00"), + ShowTime(date: Date(), time: "12:30"), + ShowTime(date: Date(), time: "14:30"), + ShowTime(date: Date(), time: "17:00"), + ShowTime(date: Date(), time: "19:30"), + ShowTime(date: Date(), time: "22:00") + ], selectedTheater: $selectedTheater, - selectedTime: $selectedTime, + selectedShowTime: $selectedShowTime, numberOfPeople: $numberOfPeople ) .padding() diff --git a/MovieBooking/Feature/MovieBook/MovieBookFeature.swift b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift index ef87dff..ad643ac 100644 --- a/MovieBooking/Feature/MovieBook/MovieBookFeature.swift +++ b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift @@ -21,9 +21,9 @@ struct MovieBookFeature { let posterPath: String let title: String var theaters: [MovieTheater] = [] - var times: [String] = [] + var showTimes: [ShowTime] = [] var selectedTheater: MovieTheater? - var selectedTime: String? + var selectedShowTime: ShowTime? var numberOfPeople: Int = 1 let pricePerTicket: Int = 13000 var isBookingInProgress: Bool = false @@ -40,14 +40,14 @@ struct MovieBookFeature { enum ViewAction { case onAppear case theaterSelected(MovieTheater) - case timeSelected(String) + case showTimeSelected(ShowTime) case numberOfPeopleChanged(Int) case onTapBookButton } enum AsyncAction { case fetchTheatersResponse(Result<[MovieTheater], Error>) - case fetchTimesResponse(Result<[String], Error>) + case fetchTimesResponse(Result<[ShowTime], Error>) case createBookingResponse(Result) } @@ -68,9 +68,9 @@ struct MovieBookFeature { switch action { case .binding(\.numberOfPeople): return .send(.view(.numberOfPeopleChanged(state.numberOfPeople))) - case .binding(\.selectedTime): - guard let selectedTime = state.selectedTime else { return .none } - return .send(.view(.timeSelected(selectedTime))) + case .binding(\.selectedShowTime): + guard let selectedShowTime = state.selectedShowTime else { return .none } + return .send(.view(.showTimeSelected(selectedShowTime))) case .binding(\.selectedTheater): guard let selectedTheater = state.selectedTheater else { return .none } return .send(.view(.theaterSelected(selectedTheater))) @@ -101,13 +101,13 @@ extension MovieBookFeature { case .theaterSelected(let theater): state.selectedTheater = theater - state.selectedTime = nil - state.times = [] + state.selectedShowTime = nil + state.showTimes = [] return .send(.inner(.fetchTimes(theaterId: theater.id))) - case .timeSelected(let time): - state.selectedTime = time - print(time) + case .showTimeSelected(let showTime): + state.selectedShowTime = showTime + print(showTime.fullDisplay) return .none case .numberOfPeopleChanged(let count): @@ -117,7 +117,7 @@ extension MovieBookFeature { case .onTapBookButton: // 유효성 검사 guard state.selectedTheater != nil, - state.selectedTime != nil else { + state.selectedShowTime != nil else { state.alert = AlertState(title: { TextState("예매 오류") }, actions: { @@ -147,7 +147,7 @@ extension MovieBookFeature { return .none case .fetchTimesResponse(.success(let times)): - state.times = times + state.showTimes = times return .none case .fetchTimesResponse(.failure): @@ -222,7 +222,7 @@ extension MovieBookFeature { case .createBooking: guard let theater = state.selectedTheater, - let time = state.selectedTime else { + let showTime = state.selectedShowTime else { return .none } @@ -234,7 +234,8 @@ extension MovieBookFeature { posterPath: state.posterPath, theaterId: theater.id, theaterName: theater.name, - showTime: time, + showDate: showTime.date, + showTime: showTime.time, numberOfPeople: state.numberOfPeople, totalPrice: state.pricePerTicket * state.numberOfPeople ) diff --git a/MovieBooking/Feature/MovieBook/MovieBookView.swift b/MovieBooking/Feature/MovieBook/MovieBookView.swift index 7d831dd..cd26d3f 100644 --- a/MovieBooking/Feature/MovieBook/MovieBookView.swift +++ b/MovieBooking/Feature/MovieBook/MovieBookView.swift @@ -19,9 +19,9 @@ struct MovieBookView: View { // 극장/시간/인원 선택 카드 BookingOptionsCard( theaters: store.theaters, - times: store.times, + showTimes: store.showTimes, selectedTheater: $store.selectedTheater, - selectedTime: $store.selectedTime, + selectedShowTime: $store.selectedShowTime, numberOfPeople: $store.numberOfPeople ) // 결제 정보 카드 From a1947c3a3fbb73d2b6dfe0ba3f55ded39a545c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Sun, 19 Oct 2025 16:51:57 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat.=20=EC=98=88=EB=A7=A4=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20UI=20Feature=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/UseCase/FetchBookingsUseCase.swift | 38 ++++++ .../BookingList/BookingListFeature.swift | 120 ++++++++++++++++++ .../Feature/BookingList/BookingListView.swift | 108 ++++++++++++++++ .../BookingList/Components/BookingCard.swift | 115 +++++++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 MovieBooking/Domain/UseCase/FetchBookingsUseCase.swift create mode 100644 MovieBooking/Feature/BookingList/BookingListFeature.swift create mode 100644 MovieBooking/Feature/BookingList/BookingListView.swift create mode 100644 MovieBooking/Feature/BookingList/Components/BookingCard.swift diff --git a/MovieBooking/Domain/UseCase/FetchBookingsUseCase.swift b/MovieBooking/Domain/UseCase/FetchBookingsUseCase.swift new file mode 100644 index 0000000..4d88f26 --- /dev/null +++ b/MovieBooking/Domain/UseCase/FetchBookingsUseCase.swift @@ -0,0 +1,38 @@ +// +// FetchBookingsUseCase.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation +import Dependencies + +protocol FetchBookingsUseCaseProtocol { + func execute() async throws -> [BookingInfo] +} + +struct FetchBookingsUseCase: FetchBookingsUseCaseProtocol { + @Dependency(\.bookingRepository) var repository + + func execute() async throws -> [BookingInfo] { + try await Task.sleep(nanoseconds: 300_000_000) // 0.3초 + let bookings = try await repository.fetchBookings() + + // 예매 날짜 최신순으로 정렬 + return bookings.sorted { $0.bookedAt > $1.bookedAt } + } +} + +private enum FetchBookingsUseCaseKey: DependencyKey { + static let liveValue: FetchBookingsUseCaseProtocol = FetchBookingsUseCase() + static let previewValue: FetchBookingsUseCaseProtocol = FetchBookingsUseCase() + static let testValue: FetchBookingsUseCaseProtocol = FetchBookingsUseCase() +} + +extension DependencyValues { + var fetchBookingsUseCase: FetchBookingsUseCaseProtocol { + get { self[FetchBookingsUseCaseKey.self] } + set { self[FetchBookingsUseCaseKey.self] = newValue } + } +} diff --git a/MovieBooking/Feature/BookingList/BookingListFeature.swift b/MovieBooking/Feature/BookingList/BookingListFeature.swift new file mode 100644 index 0000000..302feb9 --- /dev/null +++ b/MovieBooking/Feature/BookingList/BookingListFeature.swift @@ -0,0 +1,120 @@ +// +// BookingListFeature.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import Foundation +import ComposableArchitecture + +@Reducer +struct BookingListFeature { + @Dependency(\.fetchBookingsUseCase) var fetchBookingsUseCase + + @ObservableState + struct State { + var bookings: [BookingInfo] = [] + var isLoading: Bool = false + var errorMessage: String? + } + + enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(ViewAction) + case async(AsyncAction) + case inner(InnerAction) + + enum ViewAction { + case onAppear + case refreshButtonTapped + case deleteBooking(id: String) + } + + enum AsyncAction { + case fetchBookingsResponse(Result<[BookingInfo], Error>) + } + + enum InnerAction { + case fetchBookings + } + } + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case .view(let viewAction): + return handleViewAction(&state, viewAction) + + case .async(let asyncAction): + return handleAsyncAction(&state, asyncAction) + + case .inner(let innerAction): + return handleInnerAction(&state, innerAction) + } + } + } +} + +extension BookingListFeature { + func handleViewAction( + _ state: inout State, + _ action: Action.ViewAction + ) -> Effect { + switch action { + case .onAppear: + return .send(.inner(.fetchBookings)) + + case .refreshButtonTapped: + return .send(.inner(.fetchBookings)) + + case .deleteBooking(let id): + // TODO: DeleteBookingUseCase 구현 후 처리 + state.bookings.removeAll { $0.id == id } + return .none + } + } + + func handleAsyncAction( + _ state: inout State, + _ action: Action.AsyncAction + ) -> Effect { + switch action { + case .fetchBookingsResponse(.success(let bookings)): + state.isLoading = false + state.bookings = bookings + state.errorMessage = nil + return .none + + case .fetchBookingsResponse(.failure(let error)): + state.isLoading = false + state.errorMessage = error.localizedDescription + return .none + } + } + + func handleInnerAction( + _ state: inout State, + _ action: Action.InnerAction + ) -> Effect { + switch action { + case .fetchBookings: + state.isLoading = true + return .run { send in + await send( + .async( + .fetchBookingsResponse( + Result { + try await fetchBookingsUseCase.execute() + } + ) + ) + ) + } + } + } +} diff --git a/MovieBooking/Feature/BookingList/BookingListView.swift b/MovieBooking/Feature/BookingList/BookingListView.swift new file mode 100644 index 0000000..7de5d49 --- /dev/null +++ b/MovieBooking/Feature/BookingList/BookingListView.swift @@ -0,0 +1,108 @@ +// +// BookingListView.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import SwiftUI +import ComposableArchitecture + +@ViewAction(for: BookingListFeature.self) +struct BookingListView: View { + @Perception.Bindable var store: StoreOf + + var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: 4) { + Text("예매 내역") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color.primary) + + Text("내가 예매한 영화를 확인하세요") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(Color.secondary) + } + Group { + if store.isLoading { + ProgressView("예매 내역 불러오는 중...") + } else if store.bookings.isEmpty { + emptyView + } else { + bookingList + } + } + } + .padding() + .onAppear { + send(.onAppear) + } + } + } + + @ViewBuilder + private var emptyView: some View { + VStack(spacing: 16) { + Image(systemName: "ticket") + .font(.system(size: 60)) + .foregroundColor(.gray) + + Text("예매 내역이 없습니다") + .font(.headline) + .foregroundColor(.gray) + + Text("영화를 예매하면 여기에 표시됩니다") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private var bookingList: some View { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(store.bookings) { booking in + BookingCard(booking: booking) { + send(.deleteBooking(id: booking.id)) + } + } + } + } + } +} + +#Preview { + BookingListView( + store: Store( + initialState: BookingListFeature.State( + bookings: [ + BookingInfo( + movieId: "1", + movieTitle: "The Dark Knight", + posterPath: "/bUrReoZFLGti6ehkBW0xw8f12MT.jpg", + theaterId: 1, + theaterName: "CGV 강남", + showDate: Date().addingTimeInterval(86400 * 3), + showTime: "14:30", + numberOfPeople: 2, + totalPrice: 26000 + ), + BookingInfo( + movieId: "2", + movieTitle: "Inception", + posterPath: "/9gk7adHYeDvHkCSEqAvQNLV5Uge.jpg", + theaterId: 2, + theaterName: "메가박스 코엑스", + showDate: Date().addingTimeInterval(86400 * 5), + showTime: "19:00", + numberOfPeople: 1, + totalPrice: 13000 + ) + ] + ) + ) { + BookingListFeature() + } + ) +} diff --git a/MovieBooking/Feature/BookingList/Components/BookingCard.swift b/MovieBooking/Feature/BookingList/Components/BookingCard.swift new file mode 100644 index 0000000..11abf99 --- /dev/null +++ b/MovieBooking/Feature/BookingList/Components/BookingCard.swift @@ -0,0 +1,115 @@ +// +// BookingCard.swift +// MovieBooking +// +// Created by 홍석현 on 10/19/25. +// + +import SwiftUI + +struct BookingCard: View { + let booking: BookingInfo + let onDelete: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 16) { + // 포스터 이미지 + AsyncImage(url: URL(string: "https://image.tmdb.org/t/p/w200\(booking.posterPath)")) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.3)) + } + .frame(width: 80, height: 120) + .cornerRadius(8) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(booking.movieTitle) + .font(.headline) + .lineLimit(2) + + Spacer() + + Button(role: .destructive) { + onDelete() + } label: { + Image(systemName: "trash") + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundColor(.red) + .cornerRadius(8) + } + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: "building.2") + .font(.caption) + Text(booking.theaterName) + .font(.subheadline) + } + + HStack { + Image(systemName: "calendar") + .font(.caption) + Text(booking.displayShowDate) + .font(.subheadline) + } + + HStack { + Image(systemName: "clock") + .font(.caption) + Text(booking.showTime) + .font(.subheadline) + } + + HStack { + Image(systemName: "person.2") + .font(.caption) + Text("\(booking.numberOfPeople)명") + .font(.subheadline) + } + } + .foregroundColor(.secondary) + + Divider() + + HStack { + Text("총 결제") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Text("\(booking.totalPrice.formatted())원") + .font(.system(size: 18, weight: .light)) + .foregroundColor(.basicPurple) + } + } + } + } + .padding() + .background(Color.white) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 5, x: 0, y: 2) + } +} + +#Preview { + BookingCard(booking: BookingInfo( + movieId: "2", + movieTitle: "Inception", + posterPath: "/9gk7adHYeDvHkCSEqAvQNLV5Uge.jpg", + theaterId: 2, + theaterName: "메가박스 코엑스", + showDate: Date().addingTimeInterval(86400 * 5), + showTime: "19:00", + numberOfPeople: 1, + totalPrice: 13000 + ), onDelete: { }) +} From 2eebae0a82494ce5b731cdfb73ad24f06ed267ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A9=E1=86=BC=E1=84=89=E1=85=A5=E1=86=A8?= =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB?= Date: Sun, 19 Oct 2025 18:56:00 +0900 Subject: [PATCH 7/7] chor. --- .../Feature/BookingList/BookingListView.swift | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/MovieBooking/Feature/BookingList/BookingListView.swift b/MovieBooking/Feature/BookingList/BookingListView.swift index 7de5d49..456761c 100644 --- a/MovieBooking/Feature/BookingList/BookingListView.swift +++ b/MovieBooking/Feature/BookingList/BookingListView.swift @@ -15,15 +15,8 @@ struct BookingListView: View { var body: some View { WithPerceptionTracking { VStack(alignment: .leading) { - VStack(alignment: .leading, spacing: 4) { - Text("예매 내역") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Color.primary) - - Text("내가 예매한 영화를 확인하세요") - .font(.system(size: 16, weight: .regular)) - .foregroundStyle(Color.secondary) - } + headerView + Group { if store.isLoading { ProgressView("예매 내역 불러오는 중...") @@ -41,6 +34,19 @@ struct BookingListView: View { } } + @ViewBuilder + private var headerView: some View { + VStack(alignment: .leading, spacing: 4) { + Text("예매 내역") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color.primary) + + Text("내가 예매한 영화를 확인하세요") + .font(.system(size: 16, weight: .regular)) + .foregroundStyle(Color.secondary) + } + } + @ViewBuilder private var emptyView: some View { VStack(spacing: 16) {