From f77d519d4300b7067a947d45542213d5c1bdca6b Mon Sep 17 00:00:00 2001 From: minneee Date: Thu, 18 Sep 2025 21:50:26 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EB=B2=84=ED=8A=BC=EC=97=90=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project.pbxproj | 2 +- .../Main/Feature/ProductListFeature.swift | 14 +++- .../Main/Feature/ProductListView.swift | 77 +++++++++++-------- .../Features/ShoppingCart/CartFeature.swift | 27 +++++++ .../Features/ShoppingCart/CartView.swift | 13 +++- 5 files changed, 96 insertions(+), 37 deletions(-) create mode 100644 AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift diff --git a/AppleStoreProductKiosk.xcodeproj/project.pbxproj b/AppleStoreProductKiosk.xcodeproj/project.pbxproj index 8896660..cbbfce1 100644 --- a/AppleStoreProductKiosk.xcodeproj/project.pbxproj +++ b/AppleStoreProductKiosk.xcodeproj/project.pbxproj @@ -22,7 +22,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 7FB949782E7BB47600E5F3CF /* AppleStoreProductKiosk.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = file; path = AppleStoreProductKiosk.xctestplan; sourceTree = ""; }; + 7FB949782E7BB47600E5F3CF /* AppleStoreProductKiosk.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = AppleStoreProductKiosk.xctestplan; sourceTree = ""; }; C8F725DE2E790F9F00C2A1DE /* AppleStoreProductKiosk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppleStoreProductKiosk.app; sourceTree = BUILT_PRODUCTS_DIR; }; C8F725EB2E790FA100C2A1DE /* AppleStoreProductKioskTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AppleStoreProductKioskTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ diff --git a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift index 7e4f482..6844413 100644 --- a/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift +++ b/AppleStoreProductKiosk/Features/Main/Feature/ProductListFeature.swift @@ -37,6 +37,7 @@ public struct ProductListFeature { var cartButtonState: CartButtonFeature.State @Presents var alert: AlertState? + @Presents var cart: CartFeature.State? init(selectedProducts: Shared<[Product]>) { self._selectedProducts = selectedProducts @@ -69,6 +70,7 @@ public struct ProductListFeature { @CasePathable public enum ScopeAction: Equatable { case cardButton(CartButtonFeature.Action) + case cart(PresentationAction) } @CasePathable @@ -116,6 +118,10 @@ public struct ProductListFeature { Scope(state: \.cartButtonState, action: \.scope.cardButton) { CartButtonFeature() } + + .ifLet(\.$cart, action: \.scope.cart) { + CartFeature() + } } } @@ -166,7 +172,13 @@ extension ProductListFeature { ) -> Effect { switch action { case .cardButton(let action): - guard case .view(.onTap) = action else { return .none } + switch action { + case .view(.onTap): + state.cart = CartFeature.State(selectedProducts: state.$selectedProducts) + return .none + } + + case .cart: return .none } } diff --git a/AppleStoreProductKiosk/Features/Main/Feature/ProductListView.swift b/AppleStoreProductKiosk/Features/Main/Feature/ProductListView.swift index e2a1094..aac1a52 100644 --- a/AppleStoreProductKiosk/Features/Main/Feature/ProductListView.swift +++ b/AppleStoreProductKiosk/Features/Main/Feature/ProductListView.swift @@ -29,44 +29,24 @@ public struct ProductListView: View { } } + private var columns: [GridItem] { + Array(repeating: GridItem(.flexible(), spacing: 16), count: adaptiveColumnCount) + } + public var body: some View { WithPerceptionTracking { VStack { - Text("Apple Store") - .font(.largeTitle) - .fontWeight(.bold) - Text("원하는 제품을 선택하고 주문하세요.") - .font(.subheadline) - .foregroundStyle(Color.secondary) + headerView SegmentsView( - items: store.productCategories - .map { - SegmentData( - id: $0.id, - title: $0.name, - icon: $0.name - ) - }, + items: store.productCategories.map { + SegmentData(id: $0.id, title: $0.name, icon: $0.name) + }, selectedID: $store.currentSelectedCategoryId ) ScrollView { - LazyVGrid( - columns: Array( - repeating: GridItem(.flexible(), spacing: 16), - count: adaptiveColumnCount - ), - spacing: 16 - ) { - ForEach(store.currentItems) { product in - ProductCardView(product: product) { id in - send(.onTapAddItem(id: id)) - } - } - } - .padding(8) - + productGrid Spacer() } .scrollIndicators(.hidden) @@ -83,10 +63,43 @@ public struct ProductListView: View { .padding() } } - .onAppear { - send(.onAppear) - } + .onAppear { send(.onAppear) } .alert($store.scope(state: \.alert, action: \.view.alert)) + .cartSheet(store: store) + } + } +} + +private extension ProductListView { + var headerView: some View { + VStack { + Text("Apple Store") + .font(.largeTitle) + .fontWeight(.bold) + Text("원하는 제품을 선택하고 주문하세요.") + .font(.subheadline) + .foregroundStyle(Color.secondary) + } + } + + var productGrid: some View { + LazyVGrid(columns: columns, spacing: 16) { + ForEach(store.currentItems) { product in + ProductCardView(product: product) { id in + send(.onTapAddItem(id: id)) + } + } + } + .padding(8) + } +} + +private extension View { + func cartSheet(store: StoreOf) -> some View { + self.sheet( + store: store.scope(state: \.$cart, action: \.scope.cart) + ) { cartStore in + CartView(store: cartStore) } } } diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift b/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift new file mode 100644 index 0000000..82565d2 --- /dev/null +++ b/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift @@ -0,0 +1,27 @@ +// +// CartFeature.swift +// AppleStoreProductKiosk +// +// Created by 김민희 on 9/18/25. +// +import Foundation +import ComposableArchitecture + +@Reducer +public struct CartFeature { + @ObservableState + public struct State: Equatable { + @Shared var selectedProducts: [Product] + } + + @CasePathable + public enum Action: Equatable { + + } + + public var body: some Reducer { + Reduce { state, action in + return .none + } + } +} diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift b/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift index 44bd784..45c4d2d 100644 --- a/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift +++ b/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift @@ -6,8 +6,15 @@ // import SwiftUI +import ComposableArchitecture struct CartView: View { + let store: StoreOf + + public init(store: StoreOf) { + self.store = store + } + @State var item = CartItem() var body: some View { VStack(spacing: 0) { @@ -34,6 +41,6 @@ struct CartView: View { } } -#Preview { - CartView() -} +//#Preview { +// CartView() +//} From 238a34cd75ecb4f02a3fb65be43cf69cefbb0061 Mon Sep 17 00:00:00 2001 From: minneee Date: Fri, 19 Sep 2025 17:02:56 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[feat]=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=8B=9C=ED=8A=B8=EC=97=90=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=97=B0=EA=B2=B0=20=EB=B0=8F=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Features/ShoppingCart/CartFeature.swift | 72 +++++++++++++- .../Features/ShoppingCart/CartView.swift | 73 +++++++++++--- .../Components/CartActionButtonsView.swift | 19 ++-- .../Components/CartItemRowView.swift | 94 +++++++++++++------ .../Components/CartSummaryView.swift | 13 +-- 5 files changed, 206 insertions(+), 65 deletions(-) diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift b/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift index 82565d2..af9797d 100644 --- a/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift +++ b/AppleStoreProductKiosk/Features/ShoppingCart/CartFeature.swift @@ -12,16 +12,82 @@ public struct CartFeature { @ObservableState public struct State: Equatable { @Shared var selectedProducts: [Product] + @Presents var alert: AlertState? } @CasePathable public enum Action: Equatable { - + case decreaseQuantity(Product.ID) + case increaseQuantity(Product) + case removeProduct(Product.ID) + case clearCart + case checkoutButtonTapped + case alert(PresentationAction) + + @CasePathable + public enum Alert: Equatable { + case dismiss + } } - + public var body: some Reducer { Reduce { state, action in - return .none + switch action { + case .decreaseQuantity(let id): + state.$selectedProducts.withLock { products in + guard let index = products.firstIndex(where: { $0.id == id }) else { return } + products.remove(at: index) + } + return .none + + case .increaseQuantity(let product): + state.$selectedProducts.withLock { $0.append(product) } + return .none + + case .removeProduct(let id): + state.$selectedProducts.withLock { products in + products.removeAll { $0.id == id } + } + return .none + + case .clearCart: + state.$selectedProducts.withLock { $0.removeAll() } + return .none + + case .checkoutButtonTapped: + guard !state.selectedProducts.isEmpty else { + state.alert = AlertState { + TextState("장바구니 비어 있음") + } actions: { + ButtonState(action: .dismiss) { + TextState("확인") + } + } message: { + TextState("상품을 선택해주세요") + } + return .none + } + + let totalPrice = state.selectedProducts.reduce(0) { $0 + $1.price } + let formattedTotal = totalPrice.formatted(.currency(code: "KRW")) + state.alert = AlertState { + TextState("결제 완료") + } actions: { + ButtonState(action: .dismiss) { + TextState("확인") + } + } message: { + TextState("총 \(formattedTotal)의 주문이 접수되었습니다.\n감사합니다!") + } + state.$selectedProducts.withLock { $0.removeAll() } + return .none + + case .alert(.dismiss): + return .none + case .alert: + return .none + } } + .ifLet(\.$alert, action: \.alert) } } diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift b/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift index 45c4d2d..baced89 100644 --- a/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift +++ b/AppleStoreProductKiosk/Features/ShoppingCart/CartView.swift @@ -9,35 +9,78 @@ import SwiftUI import ComposableArchitecture struct CartView: View { - let store: StoreOf + @Perception.Bindable var store: StoreOf public init(store: StoreOf) { self.store = store } - @State var item = CartItem() var body: some View { - VStack(spacing: 0) { - CartHeaderView() + WithPerceptionTracking { + let items = makeCartItems(from: store.selectedProducts) - Divider() + VStack(spacing: 0) { + CartHeaderView() - ScrollView { - VStack(spacing: 15) { - CartItemRowView(item: $item) + Divider() + + if items.isEmpty { + emptyStateView + } else { + ScrollView { + VStack(spacing: 15) { + ForEach(items) { item in + CartItemRowView( + item: item, + onTapDecrease: { store.send(.decreaseQuantity(item.product.id)) }, + onTapIncrease: { store.send(.increaseQuantity(item.product)) }, + onTapRemove: { store.send(.removeProduct(item.product.id)) } + ) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 30) + } + .scrollIndicators(.hidden) } - .padding(.horizontal, 20) - .padding(.vertical, 30) - } - .scrollIndicators(.hidden) - Divider() + Divider() - CartSummaryView(item: $item) + CartSummaryView( + totalQuantity: items.reduce(0) { $0 + $1.quantity }, + totalPrice: items.reduce(0) { $0 + $1.subtotal } + ) .padding(.vertical, 20) - CartActionButtonsView() + CartActionButtonsView( + onTapCancel: { store.send(.clearCart) }, + onTapCheckout: { store.send(.checkoutButtonTapped) } + ) + } } + .alert($store.scope(state: \.alert, action: \.alert)) + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "cart") + .font(.system(size: 36, weight: .regular)) + .foregroundStyle(.gray.opacity(0.6)) + Text("장바구니가 비어 있어요.") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 60) + } + + private func makeCartItems(from products: [Product]) -> [CartItem] { + Dictionary(grouping: products, by: \.id) + .compactMap { _, items in + guard let product = items.first else { return nil } + return CartItem(product: product, quantity: items.count) + } + .sorted { $0.product.name < $1.product.name } } } diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartActionButtonsView.swift b/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartActionButtonsView.swift index 10c35b2..1936bbf 100644 --- a/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartActionButtonsView.swift +++ b/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartActionButtonsView.swift @@ -8,12 +8,13 @@ import SwiftUI struct CartActionButtonsView: View { + let onTapCancel: () -> Void + let onTapCheckout: () -> Void + var body: some View { Grid(horizontalSpacing: 12) { GridRow { - Button { - //전체 취소 - } label: { + Button(action: onTapCancel) { Text("전체 취소") .font(.system(size: 15, weight: .regular)) .foregroundStyle(.black) @@ -25,10 +26,8 @@ struct CartActionButtonsView: View { } } .gridCellColumns(4) - - Button { - //결제하기 - } label: { + + Button(action: onTapCheckout) { Text("결제하기") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) @@ -44,6 +43,6 @@ struct CartActionButtonsView: View { } } - #Preview { - CartActionButtonsView() - } +#Preview { + CartActionButtonsView(onTapCancel: {}, onTapCheckout: {}) +} diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartItemRowView.swift b/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartItemRowView.swift index 68f70d7..fa623de 100644 --- a/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartItemRowView.swift +++ b/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartItemRowView.swift @@ -7,37 +7,61 @@ import SwiftUI -//TODO: 데이터 작업할 때 없앨예정 -struct CartItem { - let name: String = "iphone17 pro" - let price: Int = 1000000 - let imageName: String = "iphone17pro" - var quantity: Int = 2 +struct CartItem: Identifiable, Equatable { + let product: Product + let quantity: Int + + var id: String { product.id } + var name: String { product.name } + var price: Double { product.price } + var imageURL: URL? { product.imageURL } + var subtotal: Double { product.price * Double(quantity) } + + var formattedPrice: String { + price.formatted(.currency(code: "KRW")) + } + + var formattedSubtotal: String { + subtotal.formatted(.currency(code: "KRW")) + } } struct CartItemRowView: View { - @Binding var item: CartItem + let item: CartItem + let onTapDecrease: () -> Void + let onTapIncrease: () -> Void + let onTapRemove: () -> Void var body: some View { - HStack(spacing: 0) { - Image(item.imageName) - .resizable() - .scaledToFit() - .frame(width: 80, height: 80) - .cornerRadius(10) - - Spacer() - .frame(width: 20) + HStack(spacing: 20) { + AsyncImage(url: item.imageURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure: + placeholderImage + case .empty: + ProgressView() + @unknown default: + placeholderImage + } + } + .frame(width: 80, height: 80) + .background(.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .clipped() - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 8) { Text(item.name) .font(.system(size: 17, weight: .semibold)) - Text("₩\(item.price)") + Text(item.formattedPrice) .font(.system(size: 14, weight: .regular)) .foregroundStyle(.black.opacity(0.7)) - Text("소계: ₩\(item.price * item.quantity)") + Text("소계: \(item.formattedSubtotal)") .font(.system(size: 12, weight: .regular)) .foregroundStyle(.blue) } @@ -46,9 +70,7 @@ struct CartItemRowView: View { HStack(spacing: 13) { if item.quantity > 1 { - Button { - item.quantity -= 1 - } label: { + Button(action: onTapDecrease) { Image(systemName: "minus") .font(.system(size: 15)) .padding(12) @@ -61,9 +83,7 @@ struct CartItemRowView: View { ) } } else { - Button { - //삭제 - } label: { + Button(action: onTapRemove) { Image(systemName: "trash") .font(.system(size: 13)) .padding(8) @@ -77,11 +97,10 @@ struct CartItemRowView: View { } } - Text("\(item.quantity)").frame(minWidth: 20) + Text("\(item.quantity)") + .frame(minWidth: 20) - Button { - item.quantity += 1 - } label: { + Button(action: onTapIncrease) { Image(systemName: "plus") .font(.system(size: 15)) .padding(7) @@ -100,9 +119,22 @@ struct CartItemRowView: View { .background(.gray.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 14)) } + + @ViewBuilder + private var placeholderImage: some View { + Image(systemName: "photo") + .font(.system(size: 26)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(.gray.opacity(0.5)) + } } #Preview { - @State var item = CartItem(quantity: 1) - CartItemRowView(item: $item) + CartItemRowView( + item: CartItem(product: .iPhone16Pro, quantity: 2), + onTapDecrease: {}, + onTapIncrease: {}, + onTapRemove: {} + ) + .padding() } diff --git a/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartSummaryView.swift b/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartSummaryView.swift index a278e11..9ee7057 100644 --- a/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartSummaryView.swift +++ b/AppleStoreProductKiosk/Features/ShoppingCart/Components/CartSummaryView.swift @@ -8,8 +8,9 @@ import SwiftUI struct CartSummaryView: View { - @Binding var item: CartItem - + let totalQuantity: Int + let totalPrice: Double + var body: some View { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 10) { @@ -23,9 +24,9 @@ struct CartSummaryView: View { Spacer() VStack(alignment: .trailing, spacing: 10) { - Text("\(item.quantity)개") + Text("\(totalQuantity)개") .font(.system(size: 14, weight: .semibold)) - Text("₩\(item.price * item.quantity)") + Text(totalPrice.formatted(.currency(code: "KRW"))) .font(.system(size: 20, weight: .bold)) .foregroundStyle(.blue) } @@ -36,6 +37,6 @@ struct CartSummaryView: View { } #Preview { - @State var item = CartItem() - CartSummaryView(item: $item) + CartSummaryView(totalQuantity: 3, totalPrice: 3890000) + .padding(.horizontal, 20) }