From 5cfa4df17be197ef8dbcfe39a4d4715a9d11fb35 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Sat, 24 Feb 2024 15:38:13 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20ProductInfoDetail?= =?UTF-8?q?View=20UI=20=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A0=A8=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PyeonHaeng-iOS.xcodeproj/project.pbxproj | 8 +- .../ProductInfoDetailView.swift | 95 +++++++++++++++++++ .../ProductInfoScene/ProductInfoHeader.swift | 55 ----------- .../ProductInfoScene/ProductInfoView.swift | 2 +- 4 files changed, 100 insertions(+), 60 deletions(-) create mode 100644 PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift delete mode 100644 PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift diff --git a/PyeonHaeng-iOS.xcodeproj/project.pbxproj b/PyeonHaeng-iOS.xcodeproj/project.pbxproj index 904afee..5aa8086 100644 --- a/PyeonHaeng-iOS.xcodeproj/project.pbxproj +++ b/PyeonHaeng-iOS.xcodeproj/project.pbxproj @@ -48,7 +48,7 @@ E57F2AA42B7717EA00E12B3D /* ProductInfoAPI in Frameworks */ = {isa = PBXBuildFile; productRef = E57F2AA32B7717EA00E12B3D /* ProductInfoAPI */; settings = {ATTRIBUTES = (Required, ); }; }; E57F2AA62B7717EA00E12B3D /* ProductInfoAPISupport in Frameworks */ = {isa = PBXBuildFile; productRef = E57F2AA52B7717EA00E12B3D /* ProductInfoAPISupport */; }; E57F2AA82B774CA700E12B3D /* ProductInfoDependency.swift in Sources */ = {isa = PBXBuildFile; fileRef = E57F2AA72B774CA700E12B3D /* ProductInfoDependency.swift */; }; - E5F2EC402B637D4A00EE0838 /* ProductInfoHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F2EC3F2B637D4A00EE0838 /* ProductInfoHeader.swift */; }; + E5F2EC402B637D4A00EE0838 /* ProductInfoDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F2EC3F2B637D4A00EE0838 /* ProductInfoDetailView.swift */; }; E5F2EC432B648F5B00EE0838 /* Int+.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F2EC422B648F5B00EE0838 /* Int+.swift */; }; E5F2EC452B64926100EE0838 /* PromotionTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F2EC442B64926100EE0838 /* PromotionTagView.swift */; }; /* End PBXBuildFile section */ @@ -104,7 +104,7 @@ E50584522B763C8C002FDACF /* ProductInfoViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductInfoViewModel.swift; sourceTree = ""; }; E5462C652B65677B00E9FDF2 /* PromotionTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionTag.swift; sourceTree = ""; }; E57F2AA72B774CA700E12B3D /* ProductInfoDependency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductInfoDependency.swift; sourceTree = ""; }; - E5F2EC3F2B637D4A00EE0838 /* ProductInfoHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductInfoHeader.swift; sourceTree = ""; }; + E5F2EC3F2B637D4A00EE0838 /* ProductInfoDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductInfoDetailView.swift; sourceTree = ""; }; E5F2EC422B648F5B00EE0838 /* Int+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+.swift"; sourceTree = ""; }; E5F2EC442B64926100EE0838 /* PromotionTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionTagView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -233,7 +233,7 @@ E57F2AA72B774CA700E12B3D /* ProductInfoDependency.swift */, BA28F18A2B6155BD0052855E /* ProductInfoView.swift */, E50584522B763C8C002FDACF /* ProductInfoViewModel.swift */, - E5F2EC3F2B637D4A00EE0838 /* ProductInfoHeader.swift */, + E5F2EC3F2B637D4A00EE0838 /* ProductInfoDetailView.swift */, E50176252B6A204F0098D1BE /* ProductInfoLineGraphView.swift */, ); path = ProductInfoScene; @@ -504,7 +504,7 @@ buildActionMask = 2147483647; files = ( BAE159DA2B65FC35002DCF94 /* HomeProductListView.swift in Sources */, - E5F2EC402B637D4A00EE0838 /* ProductInfoHeader.swift in Sources */, + E5F2EC402B637D4A00EE0838 /* ProductInfoDetailView.swift in Sources */, BA28F1852B6155810052855E /* OnboardingView.swift in Sources */, BAB5CF272B6B7CF3008B24BF /* HomeViewModel.swift in Sources */, BA28F1882B6155910052855E /* HomeView.swift in Sources */, diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift new file mode 100644 index 0000000..abfed92 --- /dev/null +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift @@ -0,0 +1,95 @@ +// +// ProductInfoDetailView.swift +// PyeonHaeng-iOS +// +// Created by 김응철 on 2024/1/26. +// + +import DesignSystem +import Entity +import SwiftUI + +// MARK: - ProductInfoDetailView + +struct ProductInfoDetailView: View where ViewModel: ProductInfoViewModelRepresentable { + @EnvironmentObject private var viewModel: ViewModel + + var body: some View { + VStack(spacing: 8.0) { + ImageView(product: viewModel.state.product) + DetailView(product: viewModel.state.product) + } + .padding(.bottom, 16.0) + } +} + +// MARK: - ImageView + +private struct ImageView: View { + let product: ProductDetail + + var body: some View { + AsyncImage(url: product.imageURL) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + // TODO: 편행 기본 이미지 추가 + ProgressView() + } + .frame(maxWidth: .infinity, maxHeight: Metrics.imageHeight) + .padding(.top, Metrics.imagePaddingTop) + .padding(.bottom, Metrics.imagePaddingBottom) + } +} + +// MARK: - DetailView + +private struct DetailView: View { + let product: ProductDetail + + var body: some View { + Text(product.name) + .font(.h3) + .foregroundStyle(Color.gray900) + .frame(maxWidth: .infinity, alignment: .leading) + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: .zero) { + Text("행사 진행 편의점") + .font(.c2) + .padding(.top, 16.0) + Image._7Eleven + .padding(.top, 2.0) + } + Spacer() + HStack(spacing: 8.0) { + PromotionTagView(promotionTag: promotionTag(for: product.promotion)) + Text("개당") + .font(.c1) + Text("\(Int(product.price / 2).formatted())원") + .font(.h2) + .frame(maxHeight: 38.0) + } + } + .foregroundStyle(Color.gray900) + } + + func promotionTag(for promotion: Promotion) -> PromotionTag { + switch promotion { + case .allItems: + .none + case .buyOneGetOneFree: + .onePlus + case .buyTwoGetOneFree: + .twoPlus + } + } +} + +// MARK: - Metrics + +private enum Metrics { + static let imageHeight: CGFloat = 257.0 + static let imagePaddingTop: CGFloat = 44.0 + static let imagePaddingBottom: CGFloat = 40.0 +} diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift deleted file mode 100644 index 7a1e704..0000000 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ProductInfoHeader.swift -// PyeonHaeng-iOS -// -// Created by 김응철 on 2024/1/26. -// - -import DesignSystem -import SwiftUI - -struct ProductInfoHeader: View where ViewModel: ProductInfoViewModelRepresentable { - @EnvironmentObject private var viewModel: ViewModel - - var body: some View { - VStack(spacing: 8.0) { - AsyncImage(url: viewModel.state.product.imageURL) { image in - image - .resizable() - .scaledToFit() - } placeholder: { - // TODO: 편행 기본 이미지 추가 - ProgressView() - } - .frame(maxWidth: .infinity, maxHeight: 257.0) - .padding(.top, 44.0) - .padding(.bottom, 40.0) - - Text(viewModel.state.product.name) - .font(.h3) - .foregroundStyle(Color.gray900) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: .zero) { - Text("행사 진행 편의점") - .font(.c2) - .padding(.top, 16.0) - Image._7Eleven - .padding(.top, 2.0) - } - Spacer() - HStack(spacing: 8.0) { - PromotionTagView(promotionTag: .onePlus) - Text("개당") - .font(.c1) - Text("\(Int(viewModel.state.product.price / 2).formatted())원") - .font(.h2) - .frame(maxHeight: 38.0) - } - } - .foregroundStyle(Color.gray900) - } - .padding(.bottom, 16.0) - } -} diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift index bc612e0..89509ec 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift @@ -20,7 +20,7 @@ struct ProductInfoView: View where ViewModel: ProductInfoViewModelRep var body: some View { NavigationStack { VStack { - ProductInfoHeader() + ProductInfoDetailView() ProductInfoLineGraphView(prices: [1150, 1300, 1400, 1200]) Spacer() } From 7623a0516c82bdf39ca242b0573febd55037023b Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Sat, 24 Feb 2024 19:23:42 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20ProductInfoLineGr?= =?UTF-8?q?aphView=20UI=20=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductInfoDetailView.swift | 2 +- .../ProductInfoLineGraphView.swift | 178 +++++++++++------- 2 files changed, 109 insertions(+), 71 deletions(-) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift index abfed92..83ac589 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift @@ -73,7 +73,7 @@ private struct DetailView: View { } .foregroundStyle(Color.gray900) } - + func promotionTag(for promotion: Promotion) -> PromotionTag { switch promotion { case .allItems: diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index 61a00ec..303abd4 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -16,8 +16,8 @@ struct ProductInfoLineGraphView: View { /// 임시데이터입니다. 이 데이터는 곧 편의점 상품 데이터로 교체됩니다. @State var prices: [Int] - @State private var offset: CGSize = .zero @State private var isFirstRender: Bool = false + @State private var offset: CGSize = .zero // MARK: - View @@ -26,60 +26,20 @@ struct ProductInfoLineGraphView: View { let size = reader.size let interval = size.width / CGFloat(prices.count + 1) let points = calculatePoints(interval: interval, width: size.width) - - VStack(spacing: 4.0) { - Text("이전 행사 정보") - .font(.title1) - .foregroundStyle(.gray900) - .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: Metrics.spacing) { + TextView() ZStack { - Path { $0.addLines(points) } - .stroke(.green500, style: StrokeStyle(lineWidth: 2.0)) - - Path { path in - for point in points.dropFirst().dropLast() { - var point = point - point.x -= Metrics.symbolWidth / 2 - point.y -= Metrics.symbolWidth / 2 - path.addEllipse(in: CGRect( - origin: point, - size: CGSize(width: Metrics.symbolWidth, height: Metrics.symbolWidth) - )) - } - } - .fill(.green500) - .stroke(.white, lineWidth: 1.0) - - LinearGradient( - colors: [.green500.opacity(0.1), .clear], - startPoint: .top, - endPoint: .bottom - ) - .clipShape( - Path { path in - path.move(to: .zero) - path.addLines(points) - path.addLine(to: CGPoint(x: size.width, y: size.height)) - path.addLine(to: CGPoint(x: .zero, y: size.height)) - } - ) + LineGraphView(points: points) + LineGraphSymbolView(points: points) + BackgroundGradientView(points: points, size: size) } - } - .gesture(DragGesture().onChanged { value in - let index = max(min(Int((value.location.x / interval).rounded() - 1), prices.count - 1), 0) - offset = CGSize(width: points[index + 1].x - (Metrics.panelWidth / 2), height: 0) - }) - .overlay(alignment: .bottomLeading) { - VStack(spacing: 0) { - Text(verbatim: "2023.11") - .font(.c4) - .foregroundStyle(.gray400) - Text(verbatim: "1,250원") - .font(.b1) - .foregroundStyle(.gray900) - Rectangle() - .foregroundStyle(.gray100) - .frame(width: 1.0, height: Metrics.panelPoleHeight) + .gesture(DragGesture().onChanged { value in + let index = max(min(Int((value.location.x / interval).rounded() - 1), prices.count - 1), 0) + offset = CGSize(width: points[index + 1].x - (Metrics.panelWidth / 2), height: 0) + }) + .overlay(alignment: .bottomLeading) { + LineGraphPanelView() } .frame(width: Metrics.panelWidth, height: Metrics.panelHeight) .offset(offset) @@ -99,12 +59,8 @@ struct ProductInfoLineGraphView: View { .padding(EdgeInsets(top: 4.0, leading: .zero, bottom: 24.0, trailing: .zero)) .frame(height: Metrics.frameHeight) } -} - -// MARK: Helpers - -private extension ProductInfoLineGraphView { - func calculatePoints(interval: CGFloat, width: CGFloat) -> [CGPoint] { + + private func calculatePoints(interval: CGFloat, width: CGFloat) -> [CGPoint] { var points = [CGPoint]() points.append(CGPoint(x: .zero, y: Metrics.lineMaxHeightFromTop)) @@ -122,22 +78,104 @@ private extension ProductInfoLineGraphView { } } -// MARK: ProductInfoLineGraphView.Metrics +// MARK: - TextView + +private struct TextView: View { + var body: some View { + Text("이전 행사 정보") + .font(.title1) + .foregroundStyle(.gray900) + .frame(maxWidth: .infinity, alignment: .leading) + } +} -private extension ProductInfoLineGraphView { - enum Metrics { - static let lineMaxHeightFromTop: CGFloat = 87.0 - static let lineMaxHeightFromBottom: CGFloat = lineGraphHeight - lineMaxHeightFromTop - static let symbolWidth: CGFloat = 4.0 - static let frameHeight: CGFloat = 226.0 - static let lineGraphHeight: CGFloat = 162.0 +private struct LineGraphView: View { + let points: [CGPoint] + + var body: some View { + Path { path in + path.addLines(points) + } + .stroke(.green500, style: StrokeStyle(lineWidth: 2.0)) + } +} - static let panelWidth: CGFloat = 55.0 - static let panelHeight: CGFloat = 162.0 - static let panelPoleHeight: CGFloat = 122.0 +private struct LineGraphSymbolView: View { + let points: [CGPoint] + @State var offset: CGSize = .zero + + var body: some View { + Path { path in + for point in points.dropFirst().dropLast() { + var point = point + point.x -= Metrics.symbolWidth / 2 + point.y -= Metrics.symbolWidth / 2 + path.addEllipse(in: CGRect( + origin: point, + size: CGSize(width: Metrics.symbolWidth, height: Metrics.symbolWidth) + )) + } + } + .fill(.green500) + .stroke(.white, lineWidth: 1.0) } } +private struct LineGraphPanelView: View { + var body: some View { + VStack(spacing: .zero) { + Text(verbatim: "2023.11") + .font(.c4) + .foregroundStyle(.gray400) + Text(verbatim: "1,250원") + .font(.b1) + .foregroundStyle(.gray900) + Rectangle() + .foregroundStyle(.gray100) + .frame(width: 1.0, height: Metrics.panelPoleHeight) + } + } +} + +// MARK: - BackgroundGradientView + +private struct BackgroundGradientView: View { + let points: [CGPoint] + let size: CGSize + + var body: some View { + LinearGradient( + colors: [.green500.opacity(0.1), .clear], + startPoint: .top, + endPoint: .bottom + ) + .clipShape( + Path { path in + path.move(to: .zero) + path.addLines(points) + path.addLine(to: CGPoint(x: size.width, y: size.height)) + path.addLine(to: CGPoint(x: .zero, y: size.height)) + } + ) + } +} + +// MARK: - Metrics + +private enum Metrics { + static let spacing: CGFloat = 4.0 + + static let lineMaxHeightFromTop: CGFloat = 87.0 + static let lineMaxHeightFromBottom: CGFloat = lineGraphHeight - lineMaxHeightFromTop + static let symbolWidth: CGFloat = 4.0 + static let frameHeight: CGFloat = 226.0 + static let lineGraphHeight: CGFloat = 162.0 + + static let panelWidth: CGFloat = 55.0 + static let panelHeight: CGFloat = 162.0 + static let panelPoleHeight: CGFloat = 122.0 +} + #Preview(traits: .sizeThatFitsLayout) { ProductInfoLineGraphView(prices: [1, 2]) } From c97f09e576ef65cd3d26a82ec041fa9c9060d5dd Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Sat, 24 Feb 2024 20:14:13 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=E2=9C=A8=20ProductInfoDetailView=20?= =?UTF-8?q?=ED=8E=B8=EC=9D=98=EC=A0=90=20=EB=A1=9C=EA=B3=A0=20Image=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B0=94=EC=9D=B8=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductInfoDetailView.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift index 83ac589..96c6520 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift @@ -57,12 +57,12 @@ private struct DetailView: View { VStack(alignment: .leading, spacing: .zero) { Text("행사 진행 편의점") .font(.c2) - .padding(.top, 16.0) - Image._7Eleven - .padding(.top, 2.0) + .padding(.top, Metrics.textPaddingTop) + image(for: product.convenienceStore) + .padding(.top, Metrics.convenienceStoreLogoPaddingTop) } Spacer() - HStack(spacing: 8.0) { + HStack(spacing: Metrics.horizontalSpacing) { PromotionTagView(promotionTag: promotionTag(for: product.promotion)) Text("개당") .font(.c1) @@ -84,6 +84,16 @@ private struct DetailView: View { .twoPlus } } + + func image(for convenienceStore: ConvenienceStore) -> Image { + switch convenienceStore { + case .cu: .cu + case .gs25: .gs25 + case ._7Eleven: ._7Eleven + case .emart24: .emart24 + case .ministop: .ministop + } + } } // MARK: - Metrics @@ -92,4 +102,8 @@ private enum Metrics { static let imageHeight: CGFloat = 257.0 static let imagePaddingTop: CGFloat = 44.0 static let imagePaddingBottom: CGFloat = 40.0 + + static let textPaddingTop: CGFloat = 16.0 + static let convenienceStoreLogoPaddingTop: CGFloat = 2.0 + static let horizontalSpacing: CGFloat = 8.0 } From 5ad2e543328c2b979d2d06af1dfd541a8c251dc7 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Sat, 24 Feb 2024 20:14:34 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20ProductInfoLineGr?= =?UTF-8?q?aphView=20UI=20=EC=BD=94=EB=93=9C=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Mocks/ProductInfoPriceResponse.json | 10 +-- .../ProductInfoLineGraphView.swift | 71 ++++++++++--------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json b/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json index 7778d25..ffd72bc 100644 --- a/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json +++ b/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json @@ -2,7 +2,7 @@ { "name": "힛더티)슈퍼말차초코콘150ML", "img": "https://image.woodongs.com/imgsvr/item/GD_8809490180818_002.jpg", - "price": 2500, + "price": 2400, "store": "GS25", "tag": "2+1", "proinfo": 0, @@ -12,7 +12,7 @@ { "name": "힛더티)슈퍼말차초코콘150ML", "img": "http://gs25appimg.gsretail.com/imgsvr/item/GD_8809490180818_001.jpg", - "price": 2500, + "price": 2100, "store": "GS25", "tag": "2+1", "proinfo": 0, @@ -32,7 +32,7 @@ { "name": "힛더티)슈퍼말차초코콘150ML", "img": "http://gs25appimg.gsretail.com/imgsvr/item/GD_8809490180818_001.jpg", - "price": 2500, + "price": 2600, "store": "GS25", "tag": "2+1", "proinfo": 0, @@ -42,7 +42,7 @@ { "name": "힛더티)슈퍼말차초코콘150ML", "img": "http://gs25appimg.gsretail.com/imgsvr/item/GD_8809490180818_001.jpg", - "price": 2500, + "price": 3000, "store": "GS25", "tag": "2+1", "proinfo": 0, @@ -52,7 +52,7 @@ { "name": "힛더티)슈퍼말차초코콘150ML", "img": "http://gs25appimg.gsretail.com/imgsvr/item/GD_8809490180818_001.jpg", - "price": 2500, + "price": 2900, "store": "GS25", "tag": "2+1", "proinfo": 0, diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index 303abd4..dae791a 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -10,12 +10,8 @@ import SwiftUI // MARK: - ProductInfoLineGraphView -struct ProductInfoLineGraphView: View { - // MARK: - Properties - - /// 임시데이터입니다. 이 데이터는 곧 편의점 상품 데이터로 교체됩니다. - @State var prices: [Int] - +struct ProductInfoLineGraphView: View where ViewModel: ProductInfoViewModelRepresentable { + @EnvironmentObject private var viewModel: ViewModel @State private var isFirstRender: Bool = false @State private var offset: CGSize = .zero @@ -24,9 +20,10 @@ struct ProductInfoLineGraphView: View { var body: some View { GeometryReader { reader in let size = reader.size - let interval = size.width / CGFloat(prices.count + 1) + let previousProductsCount = viewModel.state.previousProducts.count + let interval = size.width / CGFloat(previousProductsCount + 1) let points = calculatePoints(interval: interval, width: size.width) - + VStack(spacing: Metrics.spacing) { TextView() ZStack { @@ -34,19 +31,16 @@ struct ProductInfoLineGraphView: View { LineGraphSymbolView(points: points) BackgroundGradientView(points: points, size: size) } - .gesture(DragGesture().onChanged { value in - let index = max(min(Int((value.location.x / interval).rounded() - 1), prices.count - 1), 0) - offset = CGSize(width: points[index + 1].x - (Metrics.panelWidth / 2), height: 0) - }) - .overlay(alignment: .bottomLeading) { - LineGraphPanelView() - } - .frame(width: Metrics.panelWidth, height: Metrics.panelHeight) - .offset(offset) - } - .onAppear { - isFirstRender = true } + .gesture(DragGesture().onChanged { value in + let index = max(min( + Int((value.location.x / interval).rounded() - 1), + previousProductsCount - 1 + ), 0) + offset = CGSize(width: points[index + 1].x - (Metrics.panelWidth / 2), height: 0) + }) + .overlay(alignment: .bottomLeading) { LineGraphPanelView(offset: offset) } + .onAppear { isFirstRender = true } .onChange(of: isFirstRender) { guard isFirstRender else { return } if let lastPoint = points.dropLast().last { @@ -56,11 +50,17 @@ struct ProductInfoLineGraphView: View { } } } - .padding(EdgeInsets(top: 4.0, leading: .zero, bottom: 24.0, trailing: .zero)) + .padding(EdgeInsets( + top: Metrics.paddingTop, + leading: .zero, + bottom: Metrics.paddingBottom, + trailing: .zero + )) .frame(height: Metrics.frameHeight) } - + private func calculatePoints(interval: CGFloat, width: CGFloat) -> [CGPoint] { + let prices = viewModel.state.previousProducts.map(\.price) var points = [CGPoint]() points.append(CGPoint(x: .zero, y: Metrics.lineMaxHeightFromTop)) @@ -89,21 +89,23 @@ private struct TextView: View { } } +// MARK: - LineGraphView + private struct LineGraphView: View { let points: [CGPoint] - + var body: some View { - Path { path in - path.addLines(points) - } - .stroke(.green500, style: StrokeStyle(lineWidth: 2.0)) + Path { $0.addLines(points) } + .stroke(.green500, style: StrokeStyle(lineWidth: 2.0)) } } +// MARK: - LineGraphSymbolView + private struct LineGraphSymbolView: View { let points: [CGPoint] @State var offset: CGSize = .zero - + var body: some View { Path { path in for point in points.dropFirst().dropLast() { @@ -121,7 +123,11 @@ private struct LineGraphSymbolView: View { } } +// MARK: - LineGraphPanelView + private struct LineGraphPanelView: View { + let offset: CGSize + var body: some View { VStack(spacing: .zero) { Text(verbatim: "2023.11") @@ -134,6 +140,8 @@ private struct LineGraphPanelView: View { .foregroundStyle(.gray100) .frame(width: 1.0, height: Metrics.panelPoleHeight) } + .frame(width: Metrics.panelWidth, height: Metrics.panelHeight) + .offset(offset) } } @@ -165,6 +173,9 @@ private struct BackgroundGradientView: View { private enum Metrics { static let spacing: CGFloat = 4.0 + static let paddingTop = 4.0 + static let paddingBottom = 24.0 + static let lineMaxHeightFromTop: CGFloat = 87.0 static let lineMaxHeightFromBottom: CGFloat = lineGraphHeight - lineMaxHeightFromTop static let symbolWidth: CGFloat = 4.0 @@ -175,7 +186,3 @@ private enum Metrics { static let panelHeight: CGFloat = 162.0 static let panelPoleHeight: CGFloat = 122.0 } - -#Preview(traits: .sizeThatFitsLayout) { - ProductInfoLineGraphView(prices: [1, 2]) -} From 63e56fb47736026fdbfae72943df0f70f0095623 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Sun, 25 Feb 2024 00:24:21 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=8C=B1=20DetailProduct=EB=A1=9C=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?ViewModel=20=EC=A0=9C=EB=84=A4=EB=A6=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProductInfoAPI/ProductInfoService.swift | 17 ++++++++------- .../Responses/ProductDetailResponse.swift | 1 + ...roductDetail.swift => DetailProduct.swift} | 21 ++++++++++++++++--- .../ProductInfoDetailView.swift | 4 ++-- .../ProductInfoScene/ProductInfoView.swift | 2 +- .../ProductInfoViewModel.swift | 15 +++---------- 6 files changed, 34 insertions(+), 26 deletions(-) rename Entity/Sources/Entity/{ProductDetail.swift => DetailProduct.swift} (68%) diff --git a/APIService/Sources/ProductInfoAPI/ProductInfoService.swift b/APIService/Sources/ProductInfoAPI/ProductInfoService.swift index 5089414..1c6c6cd 100644 --- a/APIService/Sources/ProductInfoAPI/ProductInfoService.swift +++ b/APIService/Sources/ProductInfoAPI/ProductInfoService.swift @@ -12,8 +12,8 @@ import Network // MARK: - ProductInfoServiceRepresentable public protocol ProductInfoServiceRepresentable { - func fetchProduct() async throws -> ProductDetail - func fetchProductPrice() async throws -> [ProductDetail] + func fetchProduct() async throws -> DetailProduct + func fetchProductPrice() async throws -> [DetailProduct] } // MARK: - ProductInfoService @@ -31,22 +31,22 @@ public struct ProductInfoService { // MARK: ProductInfoServiceRepresentable extension ProductInfoService: ProductInfoServiceRepresentable { - public func fetchProduct() async throws -> ProductDetail { + public func fetchProduct() async throws -> DetailProduct { let response: ProductDetailResponse = try await network.request( with: ProductInfoEndPoint.fetchProduct(productID) ) - return ProductDetail(dto: response) + return DetailProduct(dto: response) } - public func fetchProductPrice() async throws -> [ProductDetail] { + public func fetchProductPrice() async throws -> [DetailProduct] { let response: [ProductDetailResponse] = try await network.request( with: ProductInfoEndPoint.fetchPrices(productID) ) - return response.map(ProductDetail.init(dto:)) + return response.map(DetailProduct.init(dto:)) } } -private extension ProductDetail { +private extension DetailProduct { init(dto: ProductDetailResponse) { self.init( id: dto.id, @@ -54,7 +54,8 @@ private extension ProductDetail { price: dto.price, name: dto.name, promotion: dto.tag, - convenienceStore: dto.store + convenienceStore: dto.store, + date: dto.date ) } } diff --git a/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift b/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift index 77cc809..fa4884d 100644 --- a/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift +++ b/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift @@ -15,5 +15,6 @@ struct ProductDetailResponse: Decodable { let store: ConvenienceStore let tag: Promotion let proinfo: Int + let date: String let id: Int } diff --git a/Entity/Sources/Entity/ProductDetail.swift b/Entity/Sources/Entity/DetailProduct.swift similarity index 68% rename from Entity/Sources/Entity/ProductDetail.swift rename to Entity/Sources/Entity/DetailProduct.swift index e61aecc..34f99e9 100644 --- a/Entity/Sources/Entity/ProductDetail.swift +++ b/Entity/Sources/Entity/DetailProduct.swift @@ -1,5 +1,5 @@ // -// ProductDetail.swift +// DetailProduct.swift // // // Created by 김응철 on 2/6/24. @@ -8,7 +8,7 @@ import Foundation /// 편의점 행사 제품에 대한 Entity Model입니다. -public struct ProductDetail: Identifiable { +public struct DetailProduct: Identifiable, Equatable { /// 제품 고유 Identifier public let id: Int @@ -27,6 +27,9 @@ public struct ProductDetail: Identifiable { /// 편의점 public let convenienceStore: ConvenienceStore + /// 날짜 + public let date: String + // TODO: 카테고리 상수 추가하기 public init( @@ -35,7 +38,8 @@ public struct ProductDetail: Identifiable { price: Int, name: String, promotion: Promotion, - convenienceStore: ConvenienceStore + convenienceStore: ConvenienceStore, + date: String ) { self.id = id self.imageURL = imageURL @@ -43,5 +47,16 @@ public struct ProductDetail: Identifiable { self.name = name self.promotion = promotion self.convenienceStore = convenienceStore + self.date = date + } + + public init() { + id = 0 + imageURL = nil + price = 0 + name = "" + promotion = .buyOneGetOneFree + convenienceStore = ._7Eleven + date = "" } } diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift index 96c6520..ea26483 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoDetailView.swift @@ -26,7 +26,7 @@ struct ProductInfoDetailView: View where ViewModel: ProductInfoViewMo // MARK: - ImageView private struct ImageView: View { - let product: ProductDetail + let product: DetailProduct var body: some View { AsyncImage(url: product.imageURL) { image in @@ -46,7 +46,7 @@ private struct ImageView: View { // MARK: - DetailView private struct DetailView: View { - let product: ProductDetail + let product: DetailProduct var body: some View { Text(product.name) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift index 89509ec..990442f 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoView.swift @@ -21,7 +21,7 @@ struct ProductInfoView: View where ViewModel: ProductInfoViewModelRep NavigationStack { VStack { ProductInfoDetailView() - ProductInfoLineGraphView(prices: [1150, 1300, 1400, 1200]) + ProductInfoLineGraphView() Spacer() } .environmentObject(viewModel) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift index 96b3d80..a5610f5 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift @@ -20,19 +20,10 @@ enum ProductInfoAction { // MARK: - ProductInfoState struct ProductInfoState { - var product: ProductDetail = mockProduct - var previousProducts: [ProductDetail] = [] + var product: DetailProduct = .init() + var previousProducts: [DetailProduct] = [] } -private let mockProduct = ProductDetail( - id: 0, - imageURL: nil, - price: 0, - name: "", - promotion: .allItems, - convenienceStore: ._7Eleven -) - // MARK: - ProductInfoViewModelRepresentable @MainActor @@ -76,6 +67,6 @@ final class ProductInfoViewModel: ProductInfoViewModelRepresentable { } private func fetchProductPrices() async throws { - try await state.previousProducts = service.fetchProductPrice() + try await state.previousProducts = service.fetchProductPrice().reversed() } } From 11a6f66ba52cc358858a44f8c534968055dd1e91 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Sun, 25 Feb 2024 00:26:30 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=F0=9F=92=84=20ProductInfoLineGraph=20?= =?UTF-8?q?=EB=8C=80=ED=8F=AD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - View에 하위뷰를 나눠 구조화를 진행 - View - VieWModel 바인딩 - 기존 calculatePoints() 가 너무 많이 불리는 문제 수정 --- .../ProductInfoLineGraphView.swift | 150 +++++++++++------- 1 file changed, 97 insertions(+), 53 deletions(-) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index dae791a..ee65730 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -6,75 +6,92 @@ // import DesignSystem +import Entity +import Log import SwiftUI // MARK: - ProductInfoLineGraphView struct ProductInfoLineGraphView: View where ViewModel: ProductInfoViewModelRepresentable { @EnvironmentObject private var viewModel: ViewModel - @State private var isFirstRender: Bool = false + @State private var offset: CGSize = .zero + @State private var index: Int = 0 + @State private var frameSize: CGSize = .zero + @State private var symbolLocations: [CGPoint] = [] + + private var interval: CGFloat { + frameSize.width / CGFloat(viewModel.state.previousProducts.count + 1) + } + + private var previousDetailProductsCount: Int { + viewModel.state.previousProducts.count + } // MARK: - View var body: some View { GeometryReader { reader in - let size = reader.size - let previousProductsCount = viewModel.state.previousProducts.count - let interval = size.width / CGFloat(previousProductsCount + 1) - let points = calculatePoints(interval: interval, width: size.width) - VStack(spacing: Metrics.spacing) { TextView() ZStack { - LineGraphView(points: points) - LineGraphSymbolView(points: points) - BackgroundGradientView(points: points, size: size) + LineGraphView(locations: symbolLocations) + LineGraphSymbolView(locations: symbolLocations) + BackgroundGradientView(locations: symbolLocations, frameSize: frameSize) } - } - .gesture(DragGesture().onChanged { value in - let index = max(min( - Int((value.location.x / interval).rounded() - 1), - previousProductsCount - 1 - ), 0) - offset = CGSize(width: points[index + 1].x - (Metrics.panelWidth / 2), height: 0) - }) - .overlay(alignment: .bottomLeading) { LineGraphPanelView(offset: offset) } - .onAppear { isFirstRender = true } - .onChange(of: isFirstRender) { - guard isFirstRender else { return } - if let lastPoint = points.dropLast().last { - offset = CGSize(width: lastPoint.x - (Metrics.panelWidth / 2), height: 0) - } else { - // TODO: 이전 행사 내역이 하나도 없을 때, UI를 숨길 것인지, 아니면 다른 조치 방법이 있는지? + .onChange(of: viewModel.state.previousProducts) { _, newProducts in + frameSize = reader.size + getSymbolLocations(newProducts) + offset = CGSize( + width: symbolLocations[newProducts.count].x - (Metrics.panelWidth / 2), + height: 0 + ) + index = newProducts.count - 1 + } + .gesture(DragGesture().onChanged { viewDidDrag($0) }) + .overlay(alignment: .bottomLeading) { + LineGraphPanelView( + position: (offset, index), + products: viewModel.state.previousProducts + ) } } + .padding(EdgeInsets( + top: Metrics.paddingTop, + leading: .zero, + bottom: Metrics.paddingBottom, + trailing: .zero + )) } - .padding(EdgeInsets( - top: Metrics.paddingTop, - leading: .zero, - bottom: Metrics.paddingBottom, - trailing: .zero - )) .frame(height: Metrics.frameHeight) } +} - private func calculatePoints(interval: CGFloat, width: CGFloat) -> [CGPoint] { - let prices = viewModel.state.previousProducts.map(\.price) - var points = [CGPoint]() - points.append(CGPoint(x: .zero, y: Metrics.lineMaxHeightFromTop)) +private extension ProductInfoLineGraphView { + func getSymbolLocations(_ products: [DetailProduct]) { + let prices = products.map(\.price) + var locations = [CGPoint]() + locations.append(CGPoint(x: .zero, y: Metrics.lineMaxHeightFromTop)) if let maxPrice = prices.max(), maxPrice != 0 { for (index, price) in prices.enumerated() { let heightFactor = 1 - (CGFloat(price) / CGFloat(maxPrice)) let pathHeight = Metrics.lineMaxHeightFromTop + Metrics.lineMaxHeightFromBottom * heightFactor let pathWidth = interval * CGFloat(index + 1) - points.append(CGPoint(x: pathWidth, y: pathHeight)) + locations.append(CGPoint(x: pathWidth, y: pathHeight)) } } - points.append(CGPoint(x: width, y: Metrics.lineMaxHeightFromTop)) - return points + locations.append(CGPoint(x: frameSize.width, y: Metrics.lineMaxHeightFromTop)) + symbolLocations = locations + } + + func viewDidDrag(_ value: DragGesture.Value) { + let index = max(min(Int(( + value.location.x / interval).rounded() - 1 + ), previousDetailProductsCount - 1), 0) + self.index = index + offset = CGSize(width: symbolLocations[index + 1].x - (Metrics.panelWidth / 2), height: 0) } } @@ -92,10 +109,10 @@ private struct TextView: View { // MARK: - LineGraphView private struct LineGraphView: View { - let points: [CGPoint] + let locations: [CGPoint] var body: some View { - Path { $0.addLines(points) } + Path { $0.addLines(locations) } .stroke(.green500, style: StrokeStyle(lineWidth: 2.0)) } } @@ -103,12 +120,11 @@ private struct LineGraphView: View { // MARK: - LineGraphSymbolView private struct LineGraphSymbolView: View { - let points: [CGPoint] - @State var offset: CGSize = .zero + let locations: [CGPoint] var body: some View { Path { path in - for point in points.dropFirst().dropLast() { + for point in locations.dropFirst().dropLast() { var point = point point.x -= Metrics.symbolWidth / 2 point.y -= Metrics.symbolWidth / 2 @@ -126,14 +142,30 @@ private struct LineGraphSymbolView: View { // MARK: - LineGraphPanelView private struct LineGraphPanelView: View { - let offset: CGSize + let position: (offset: CGSize, index: Int) + let product: DetailProduct + let isHidden: Bool + + init( + position: (offset: CGSize, index: Int), + products: [DetailProduct] + ) { + self.position = position + if products.isEmpty { + isHidden = true + product = .init() + } else { + isHidden = false + product = products[position.index] + } + } var body: some View { VStack(spacing: .zero) { - Text(verbatim: "2023.11") + Text(verbatim: "\(formatted(product.date))") .font(.c4) .foregroundStyle(.gray400) - Text(verbatim: "1,250원") + Text("\((product.price / 2).formatted())원") .font(.b1) .foregroundStyle(.gray900) Rectangle() @@ -141,15 +173,27 @@ private struct LineGraphPanelView: View { .frame(width: 1.0, height: Metrics.panelPoleHeight) } .frame(width: Metrics.panelWidth, height: Metrics.panelHeight) - .offset(offset) + .offset(position.offset) + .opacity(isHidden ? 0 : 1) + } + + func formatted(_ dateString: String) -> String { + var returnString = "" + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + if let date = formatter.date(from: dateString) { + formatter.dateFormat = "yyyy.MM" + returnString = formatter.string(from: date) + } + return returnString } } // MARK: - BackgroundGradientView private struct BackgroundGradientView: View { - let points: [CGPoint] - let size: CGSize + let locations: [CGPoint] + let frameSize: CGSize var body: some View { LinearGradient( @@ -160,9 +204,9 @@ private struct BackgroundGradientView: View { .clipShape( Path { path in path.move(to: .zero) - path.addLines(points) - path.addLine(to: CGPoint(x: size.width, y: size.height)) - path.addLine(to: CGPoint(x: .zero, y: size.height)) + path.addLines(locations) + path.addLine(to: CGPoint(x: frameSize.width, y: frameSize.height)) + path.addLine(to: CGPoint(x: .zero, y: frameSize.height)) } ) } @@ -182,7 +226,7 @@ private enum Metrics { static let frameHeight: CGFloat = 226.0 static let lineGraphHeight: CGFloat = 162.0 - static let panelWidth: CGFloat = 55.0 + static let panelWidth: CGFloat = 60.0 static let panelHeight: CGFloat = 162.0 static let panelPoleHeight: CGFloat = 122.0 } From ff9704b9c458c4473072715ca85389d91cacdda8 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Mon, 26 Feb 2024 19:14:28 +0900 Subject: [PATCH 07/11] Merge branch 'main' into feature/productinfo/48 --- .../Sources/Entity/Common/Paginatable.swift | 14 +++ Entity/Sources/Entity/Common/Paginated.swift | 19 ++++ .../ConvenienceSelectBottomSheetView.swift | 100 ++++++++++++++++++ .../ProductInfoScene/ProductInfoHeader.swift | 55 ++++++++++ 4 files changed, 188 insertions(+) create mode 100644 Entity/Sources/Entity/Common/Paginatable.swift create mode 100644 Entity/Sources/Entity/Common/Paginated.swift create mode 100644 PyeonHaeng-iOS/Sources/Scenes/HomeScene/BottomSheet/ConvenienceSelectBottomSheetView.swift create mode 100644 PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift diff --git a/Entity/Sources/Entity/Common/Paginatable.swift b/Entity/Sources/Entity/Common/Paginatable.swift new file mode 100644 index 0000000..527e942 --- /dev/null +++ b/Entity/Sources/Entity/Common/Paginatable.swift @@ -0,0 +1,14 @@ +// +// Paginatable.swift +// +// +// Created by 홍승현 on 2/23/24. +// + +public protocol Paginatable { + associatedtype Model + var count: Int { get } + var hasMore: Bool { get } + + var results: [Model] { get } +} diff --git a/Entity/Sources/Entity/Common/Paginated.swift b/Entity/Sources/Entity/Common/Paginated.swift new file mode 100644 index 0000000..69cd97e --- /dev/null +++ b/Entity/Sources/Entity/Common/Paginated.swift @@ -0,0 +1,19 @@ +// +// Paginated.swift +// +// +// Created by 홍승현 on 2/23/24. +// + +/// 페이징 처리가 들어가는 모델의 경우 해당 모델을 사용합니다. +public struct Paginated: Paginatable { + public let count: Int + public let hasMore: Bool + public let results: [Model] + + public init(count: Int, hasMore: Bool, results: [Model]) { + self.count = count + self.hasMore = hasMore + self.results = results + } +} diff --git a/PyeonHaeng-iOS/Sources/Scenes/HomeScene/BottomSheet/ConvenienceSelectBottomSheetView.swift b/PyeonHaeng-iOS/Sources/Scenes/HomeScene/BottomSheet/ConvenienceSelectBottomSheetView.swift new file mode 100644 index 0000000..2ab1154 --- /dev/null +++ b/PyeonHaeng-iOS/Sources/Scenes/HomeScene/BottomSheet/ConvenienceSelectBottomSheetView.swift @@ -0,0 +1,100 @@ +// +// ConvenienceSelectBottomSheetView.swift +// PyeonHaeng-iOS +// +// Created by 홍승현 on 2/21/24. +// + +import Entity +import SwiftUI + +// MARK: - ConvenienceSelectBottomSheetView + +struct ConvenienceSelectBottomSheetView: View where ViewModel: HomeViewModelRepresentable { + @EnvironmentObject private var viewModel: ViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: Metrics.itemSpacing) { + Text("편의점 브랜드 선택") + .font(.h3) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Metrics.horizontalPadding) + + ForEach(ConvenienceStore.allCases, id: \.self) { store in + Button { + viewModel.trigger(.changeConvenienceStore(store)) + dismiss() + } label: { + ConvenienceSelectItem(convenience: store) + .frame(maxWidth: .infinity, minHeight: Metrics.itemHeight, alignment: .leading) + } + } + .padding(.horizontal, Metrics.itemHorizontalPadding) + } + .padding(.top, Metrics.topPadding) + .padding(.bottom, Metrics.bottomPadding) + } +} + +// MARK: - ConvenienceSelectItem + +private struct ConvenienceSelectItem: View { + private let convenience: ConvenienceStore + + init(convenience: ConvenienceStore) { + self.convenience = convenience + } + + var body: some View { + HStack(spacing: Metrics.itemHorizontalSpacing) { + convenienceImageView() + convenienceText() + } + } + + private func convenienceImageView() -> Image { + switch convenience { + case .cu: + .cu + case .gs25: + .gs25 + case ._7Eleven: + ._7Eleven + case .emart24: + .emart24 + case .ministop: + .ministop + } + } + + private func convenienceText() -> Text { + switch convenience { + case .cu: + Text("CU") + case .gs25: + Text("GS25") + case ._7Eleven: + Text("7-Eleven") + case .emart24: + Text("Emart 24") + case .ministop: + Text("Ministop") + } + } +} + +// MARK: - Metrics + +private enum Metrics { + static let topPadding: CGFloat = 12 + static let bottomPadding: CGFloat = 4 + static let horizontalPadding: CGFloat = 20 + + // MARK: Item + + static let itemHorizontalPadding: CGFloat = 24 + static let itemHorizontalSpacing: CGFloat = 12 + static let itemSpacing: CGFloat = 4 + static let itemHeight: CGFloat = 44 +} diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift new file mode 100644 index 0000000..7a1e704 --- /dev/null +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoHeader.swift @@ -0,0 +1,55 @@ +// +// ProductInfoHeader.swift +// PyeonHaeng-iOS +// +// Created by 김응철 on 2024/1/26. +// + +import DesignSystem +import SwiftUI + +struct ProductInfoHeader: View where ViewModel: ProductInfoViewModelRepresentable { + @EnvironmentObject private var viewModel: ViewModel + + var body: some View { + VStack(spacing: 8.0) { + AsyncImage(url: viewModel.state.product.imageURL) { image in + image + .resizable() + .scaledToFit() + } placeholder: { + // TODO: 편행 기본 이미지 추가 + ProgressView() + } + .frame(maxWidth: .infinity, maxHeight: 257.0) + .padding(.top, 44.0) + .padding(.bottom, 40.0) + + Text(viewModel.state.product.name) + .font(.h3) + .foregroundStyle(Color.gray900) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: .zero) { + Text("행사 진행 편의점") + .font(.c2) + .padding(.top, 16.0) + Image._7Eleven + .padding(.top, 2.0) + } + Spacer() + HStack(spacing: 8.0) { + PromotionTagView(promotionTag: .onePlus) + Text("개당") + .font(.c1) + Text("\(Int(viewModel.state.product.price / 2).formatted())원") + .font(.h2) + .frame(maxHeight: 38.0) + } + } + .foregroundStyle(Color.gray900) + } + .padding(.bottom, 16.0) + } +} From b2e67c020905f5c34125cbfc23cf910d65a2d0d3 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Mon, 26 Feb 2024 19:15:53 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=8C=B1=20Json=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=EB=90=9C=20=EC=BD=94=EB=93=9C=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 --- .../Responses/ProductDetailResponse.swift | 2 +- .../Mocks/ProductInfoPriceResponse.json | 12 ++++++------ .../Mocks/ProductInfoProductResponse.json | 2 +- Entity/Sources/Entity/DetailProduct.swift | 6 +++--- .../ProductInfoScene/ProductInfoLineGraphView.swift | 11 +++-------- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift b/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift index fa4884d..02013a3 100644 --- a/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift +++ b/APIService/Sources/ProductInfoAPI/Responses/ProductDetailResponse.swift @@ -15,6 +15,6 @@ struct ProductDetailResponse: Decodable { let store: ConvenienceStore let tag: Promotion let proinfo: Int - let date: String + let date: Date let id: Int } diff --git a/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json b/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json index ffd72bc..e468bdf 100644 --- a/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json +++ b/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoPriceResponse.json @@ -6,7 +6,7 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2023-06-01", + "date": "2023-06-01T00:00:00Z", "id": 33580 }, { @@ -16,7 +16,7 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2023-05-01", + "date": "2023-05-01T00:00:00Z", "id": 28766 }, { @@ -26,7 +26,7 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2023-03-01", + "date": "2023-03-01T00:00:00Z", "id": 17993 }, { @@ -36,7 +36,7 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2023-02-01", + "date": "2023-02-01T00:00:00Z", "id": 13602 }, { @@ -46,7 +46,7 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2023-01-01", + "date": "2023-01-01T00:00:00Z", "id": 9644 }, { @@ -56,7 +56,7 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2022-11-01", + "date": "2022-11-01T00:00:00Z", "id": 2792 } ] diff --git a/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoProductResponse.json b/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoProductResponse.json index daf61d1..d6f6d31 100644 --- a/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoProductResponse.json +++ b/APIService/Sources/ProductInfoAPISupport/Mocks/ProductInfoProductResponse.json @@ -5,6 +5,6 @@ "store": "GS25", "tag": "2+1", "proinfo": 0, - "date": "2023-06-01", + "date": "2023-06-01T00:00:00Z", "id": 33580 } diff --git a/Entity/Sources/Entity/DetailProduct.swift b/Entity/Sources/Entity/DetailProduct.swift index 34f99e9..b74656f 100644 --- a/Entity/Sources/Entity/DetailProduct.swift +++ b/Entity/Sources/Entity/DetailProduct.swift @@ -28,7 +28,7 @@ public struct DetailProduct: Identifiable, Equatable { public let convenienceStore: ConvenienceStore /// 날짜 - public let date: String + public let date: Date // TODO: 카테고리 상수 추가하기 @@ -39,7 +39,7 @@ public struct DetailProduct: Identifiable, Equatable { name: String, promotion: Promotion, convenienceStore: ConvenienceStore, - date: String + date: Date ) { self.id = id self.imageURL = imageURL @@ -57,6 +57,6 @@ public struct DetailProduct: Identifiable, Equatable { name = "" promotion = .buyOneGetOneFree convenienceStore = ._7Eleven - date = "" + date = .distantPast } } diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index ee65730..c04ddce 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -177,15 +177,10 @@ private struct LineGraphPanelView: View { .opacity(isHidden ? 0 : 1) } - func formatted(_ dateString: String) -> String { - var returnString = "" + func formatted(_ date: Date) -> String { let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - if let date = formatter.date(from: dateString) { - formatter.dateFormat = "yyyy.MM" - returnString = formatter.string(from: date) - } - return returnString + formatter.dateFormat = "yyyy.MM" + return formatter.string(from: date) } } From fd5efa8b856fcd8c261ce5a89ba44e51a035d5a6 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Mon, 26 Feb 2024 19:20:47 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=8E=A8=20`updateSymbolLocations`?= =?UTF-8?q?=EB=A1=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scenes/ProductInfoScene/ProductInfoLineGraphView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index c04ddce..eec0def 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -68,7 +68,7 @@ struct ProductInfoLineGraphView: View where ViewModel: ProductInfoVie } private extension ProductInfoLineGraphView { - func getSymbolLocations(_ products: [DetailProduct]) { + func updateSymbolLocations(_ products: [DetailProduct]) { let prices = products.map(\.price) var locations = [CGPoint]() locations.append(CGPoint(x: .zero, y: Metrics.lineMaxHeightFromTop)) From 64ffacb3717296019a82c284c718b4cada6aa96f Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Mon, 26 Feb 2024 19:23:20 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=F0=9F=8E=A8=20`updateSymbolLocations`?= =?UTF-8?q?=EB=A1=9C=20=EB=84=A4=EC=9D=B4=EB=B0=8D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scenes/ProductInfoScene/ProductInfoLineGraphView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index eec0def..e5a7543 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -29,7 +29,7 @@ struct ProductInfoLineGraphView: View where ViewModel: ProductInfoVie } // MARK: - View - + var body: some View { GeometryReader { reader in VStack(spacing: Metrics.spacing) { @@ -41,7 +41,7 @@ struct ProductInfoLineGraphView: View where ViewModel: ProductInfoVie } .onChange(of: viewModel.state.previousProducts) { _, newProducts in frameSize = reader.size - getSymbolLocations(newProducts) + updateSymbolLocations(newProducts) offset = CGSize( width: symbolLocations[newProducts.count].x - (Metrics.panelWidth / 2), height: 0 @@ -85,7 +85,7 @@ private extension ProductInfoLineGraphView { locations.append(CGPoint(x: frameSize.width, y: Metrics.lineMaxHeightFromTop)) symbolLocations = locations } - + func viewDidDrag(_ value: DragGesture.Value) { let index = max(min(Int(( value.location.x / interval).rounded() - 1 From 1226b36ed2b7b5fa6613db0afa00c44a9ebc4547 Mon Sep 17 00:00:00 2001 From: EungCheol Kim Date: Mon, 26 Feb 2024 20:07:13 +0900 Subject: [PATCH 11/11] =?UTF-8?q?=F0=9F=94=A5=20LineGraphPanelView=20?= =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Entity/Sources/Entity/DetailProduct.swift | 10 ---------- .../ProductInfoLineGraphView.swift | 14 +++----------- .../ProductInfoScene/ProductInfoViewModel.swift | 12 +++++++++++- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/Entity/Sources/Entity/DetailProduct.swift b/Entity/Sources/Entity/DetailProduct.swift index b74656f..6502daa 100644 --- a/Entity/Sources/Entity/DetailProduct.swift +++ b/Entity/Sources/Entity/DetailProduct.swift @@ -49,14 +49,4 @@ public struct DetailProduct: Identifiable, Equatable { self.convenienceStore = convenienceStore self.date = date } - - public init() { - id = 0 - imageURL = nil - price = 0 - name = "" - promotion = .buyOneGetOneFree - convenienceStore = ._7Eleven - date = .distantPast - } } diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift index e5a7543..d2ba70c 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoLineGraphView.swift @@ -29,7 +29,7 @@ struct ProductInfoLineGraphView: View where ViewModel: ProductInfoVie } // MARK: - View - + var body: some View { GeometryReader { reader in VStack(spacing: Metrics.spacing) { @@ -85,7 +85,7 @@ private extension ProductInfoLineGraphView { locations.append(CGPoint(x: frameSize.width, y: Metrics.lineMaxHeightFromTop)) symbolLocations = locations } - + func viewDidDrag(_ value: DragGesture.Value) { let index = max(min(Int(( value.location.x / interval).rounded() - 1 @@ -144,20 +144,13 @@ private struct LineGraphSymbolView: View { private struct LineGraphPanelView: View { let position: (offset: CGSize, index: Int) let product: DetailProduct - let isHidden: Bool init( position: (offset: CGSize, index: Int), products: [DetailProduct] ) { self.position = position - if products.isEmpty { - isHidden = true - product = .init() - } else { - isHidden = false - product = products[position.index] - } + product = products[position.index] } var body: some View { @@ -174,7 +167,6 @@ private struct LineGraphPanelView: View { } .frame(width: Metrics.panelWidth, height: Metrics.panelHeight) .offset(position.offset) - .opacity(isHidden ? 0 : 1) } func formatted(_ date: Date) -> String { diff --git a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift index a5610f5..dfbc66e 100644 --- a/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift +++ b/PyeonHaeng-iOS/Sources/Scenes/ProductInfoScene/ProductInfoViewModel.swift @@ -20,10 +20,20 @@ enum ProductInfoAction { // MARK: - ProductInfoState struct ProductInfoState { - var product: DetailProduct = .init() + var product: DetailProduct = mockProduct var previousProducts: [DetailProduct] = [] } +private let mockProduct = DetailProduct( + id: 0, + imageURL: nil, + price: 0, + name: "", + promotion: .buyOneGetOneFree, + convenienceStore: ._7Eleven, + date: .distantPast +) + // MARK: - ProductInfoViewModelRepresentable @MainActor