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..68feeac --- /dev/null +++ b/MovieBooking/Domain/Entity/BookingInfo.swift @@ -0,0 +1,61 @@ +// +// 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 showDate: Date // 상영 날짜 + let showTime: String // 상영 시간 (예: "14:30") + let numberOfPeople: Int + let totalPrice: Int + let bookedAt: Date // 예매한 날짜 + + init( + id: String = UUID().uuidString, + movieId: String, + movieTitle: String, + posterPath: String, + theaterId: Int, + theaterName: String, + showDate: Date, + 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.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/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/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/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/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/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..3e96b6a --- /dev/null +++ b/MovieBooking/Domain/UseCase/FetchMovieTimesUseCase.swift @@ -0,0 +1,84 @@ +// +// FetchMovieTimesUseCase.swift +// MovieBooking +// +// Created by 홍석현 on 10/18/25. +// + +import Foundation +import Dependencies + +protocol FetchMovieTimesUseCaseProtocol { + func execute( + _ movieId: String, + at theaterId: Int + ) async throws -> [ShowTime] +} + +struct FetchMovieTimesUseCase: FetchMovieTimesUseCaseProtocol { + func execute( + _ movieId: String, + at theaterId: Int + ) async throws -> [ShowTime] { + 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" + ] + + // 향후 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() + + while selectedIndices.count < timeCount { + let index = Int(generator.next() % UInt64(possibleTimes.count)) + selectedIndices.insert(index) + } + + return selectedIndices.sorted().map { index in + ShowTime(date: showDate, time: possibleTimes[index]) + } + } +} + +// 시드 기반 랜덤 생성기 (동일한 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 } + } +} 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..456761c --- /dev/null +++ b/MovieBooking/Feature/BookingList/BookingListView.swift @@ -0,0 +1,114 @@ +// +// 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) { + headerView + + Group { + if store.isLoading { + ProgressView("예매 내역 불러오는 중...") + } else if store.bookings.isEmpty { + emptyView + } else { + bookingList + } + } + } + .padding() + .onAppear { + send(.onAppear) + } + } + } + + @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) { + 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: { }) +} diff --git a/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift b/MovieBooking/Feature/MovieBook/Components/BookingOptionsCard.swift index e79da42..dfb6f2b 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 times: [String] - @Binding var selectedTheater: String - @Binding var selectedTime: String + let theaters: [MovieTheater] + let showTimes: [ShowTime] + @Binding var selectedTheater: MovieTheater? + @Binding var selectedShowTime: ShowTime? @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: showTimes, + selectedItem: selectedShowTime, + onSelect: { selectedShowTime = $0 }, + placeholder: "상영시간을 선택해주세요", + displayText: { "\($0.displayShortDate) \($0.time)" } ) + .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,79 +54,34 @@ 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 selectedShowTime: ShowTime? = ShowTime(date: Date(), time: "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"], + theaters: [ + MovieTheater(id: 1, name: "CGV 강남"), + MovieTheater(id: 2, name: "메가박스 코엑스"), + MovieTheater(id: 3, name: "롯데시네마 월드타워"), + MovieTheater(id: 4, name: "CGV 용산") + ], + 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/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..ad643ac --- /dev/null +++ b/MovieBooking/Feature/MovieBook/MovieBookFeature.swift @@ -0,0 +1,256 @@ +// +// MovieBookFeature.swift +// MovieBooking +// +// Created by 홍석현 on 10/18/25. +// + +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 { + let movieId: String + let posterPath: String + let title: String + var theaters: [MovieTheater] = [] + var showTimes: [ShowTime] = [] + var selectedTheater: MovieTheater? + var selectedShowTime: ShowTime? + var numberOfPeople: Int = 1 + let pricePerTicket: Int = 13000 + var isBookingInProgress: Bool = false + @Presents var alert: AlertState? + } + + enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case view(ViewAction) + case async(AsyncAction) + case inner(InnerAction) + case alert(PresentationAction) + + enum ViewAction { + case onAppear + case theaterSelected(MovieTheater) + case showTimeSelected(ShowTime) + case numberOfPeopleChanged(Int) + case onTapBookButton + } + + enum AsyncAction { + case fetchTheatersResponse(Result<[MovieTheater], Error>) + case fetchTimesResponse(Result<[ShowTime], Error>) + case createBookingResponse(Result) + } + + enum InnerAction { + case fetchTheaters + case fetchTimes(theaterId: Int) + case createBooking + } + + enum Alert { + case confirmBookingSuccess + } + } + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding(\.numberOfPeople): + return .send(.view(.numberOfPeopleChanged(state.numberOfPeople))) + 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))) + 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) + case .alert: + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} + +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.selectedShowTime = nil + state.showTimes = [] + return .send(.inner(.fetchTimes(theaterId: theater.id))) + + case .showTimeSelected(let showTime): + state.selectedShowTime = showTime + print(showTime.fullDisplay) + return .none + + case .numberOfPeopleChanged(let count): + state.numberOfPeople = max(1, count) + return .none + + case .onTapBookButton: + // 유효성 검사 + guard state.selectedTheater != nil, + state.selectedShowTime != nil else { + state.alert = AlertState(title: { + TextState("예매 오류") + }, actions: { + ButtonState(role: .cancel) { + TextState("확인") + } + }, message: { + TextState("극장과 상영시간을 모두 선택해주세요.") + }) + return .none + } + + return .send(.inner(.createBooking)) + } + } + + 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.showTimes = times + return .none + + 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 + } + } + + 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) + } + ) + ) + ) + } + + case .createBooking: + guard let theater = state.selectedTheater, + let showTime = state.selectedShowTime else { + return .none + } + + state.isBookingInProgress = true + + let bookingInfo = BookingInfo( + movieId: state.movieId, + movieTitle: state.title, + posterPath: state.posterPath, + theaterId: theater.id, + theaterName: theater.name, + showDate: showTime.date, + showTime: 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 01f094c..cd26d3f 100644 --- a/MovieBooking/Feature/MovieBook/MovieBookView.swift +++ b/MovieBooking/Feature/MovieBook/MovieBookView.swift @@ -6,55 +6,64 @@ // 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, + showTimes: store.showTimes, + selectedTheater: $store.selectedTheater, + selectedShowTime: $store.selectedShowTime, + numberOfPeople: $store.numberOfPeople + ) + // 결제 정보 카드 + PaymentInfoCard( + posterPath: store.posterPath, + title: store.title, + pricePerTicket: store.pricePerTicket, + 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) + } + } } - - 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() + } + ) }