From 65373ae418eb4cf12b19986fffd1294c682fff4a Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 20 May 2025 14:07:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive/Sources/App/AppStep.swift | 2 + SodaLive/Sources/ContentView.swift | 3 + .../MyPage/Can/Status/CanStatusView.swift | 2 +- SodaLive/Sources/MyPage/MyPageView.swift | 25 ++- .../MyPage/Point/GetPointStatusResponse.swift | 10 ++ .../Sources/MyPage/Point/PointStatusApi.swift | 55 +++++++ .../MyPage/Point/PointStatusRepository.swift | 27 ++++ .../MyPage/Point/PointStatusView.swift | 135 +++++++++++++++++ .../MyPage/Point/PointStatusViewModel.swift | 143 ++++++++++++++++++ .../Reward/GetPointRewardStatusResponse.swift | 12 ++ .../Point/Reward/PointRewardStatusView.swift | 66 ++++++++ .../Point/Use/GetPointUseStatusResponse.swift | 12 ++ .../MyPage/Point/Use/PointUseStatusView.swift | 71 +++++++++ 13 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 SodaLive/Sources/MyPage/Point/GetPointStatusResponse.swift create mode 100644 SodaLive/Sources/MyPage/Point/PointStatusApi.swift create mode 100644 SodaLive/Sources/MyPage/Point/PointStatusRepository.swift create mode 100644 SodaLive/Sources/MyPage/Point/PointStatusView.swift create mode 100644 SodaLive/Sources/MyPage/Point/PointStatusViewModel.swift create mode 100644 SodaLive/Sources/MyPage/Point/Reward/GetPointRewardStatusResponse.swift create mode 100644 SodaLive/Sources/MyPage/Point/Reward/PointRewardStatusView.swift create mode 100644 SodaLive/Sources/MyPage/Point/Use/GetPointUseStatusResponse.swift create mode 100644 SodaLive/Sources/MyPage/Point/Use/PointUseStatusView.swift diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 4498ead..41a1ee7 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -157,4 +157,6 @@ enum AppStep { case introduceCreatorAll case message + + case pointStatus(refresh: () -> Void) } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 538f6e0..d0034df 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -242,6 +242,9 @@ struct ContentView: View { case .message: MessageView() + case .pointStatus(let refresh): + PointStatusView(refresh: refresh) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift b/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift index b2518e2..6e4c21c 100644 --- a/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift +++ b/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift @@ -28,7 +28,7 @@ struct CanStatusView: View { .resizable() .frame(width: 26.7, height: 26.7) - Text("\(viewModel.totalCan) 캔") + Text("\(viewModel.totalCan)") .font(.custom(Font.bold.rawValue, size: 18.3)) .foregroundColor(Color(hex: "eeeeee")) } diff --git a/SodaLive/Sources/MyPage/MyPageView.swift b/SodaLive/Sources/MyPage/MyPageView.swift index 2b35a7b..4f6f310 100644 --- a/SodaLive/Sources/MyPage/MyPageView.swift +++ b/SodaLive/Sources/MyPage/MyPageView.swift @@ -124,14 +124,23 @@ struct MyPageView: View { .frame(width: screenSize().width - 26.7) .padding(.top, 26.7) - HStack(spacing: 6.7) { - Text("\(data.point)") - .font(.custom(Font.bold.rawValue, size: 18.3)) - .foregroundColor(.grayee) - - Image("ic_point") - .resizable() - .frame(width: 20, height: 20) + HStack { + HStack(spacing: 6.7) { + Text("\(data.point)") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.grayee) + + Image("ic_point") + .resizable() + .frame(width: 20, height: 20) + + Image("ic_forward") + .resizable() + .frame(width: 20, height: 20) + } + .onTapGesture { + AppState.shared.setAppStep(step: .pointStatus(refresh: { viewModel.getMypage() })) + } Spacer() } diff --git a/SodaLive/Sources/MyPage/Point/GetPointStatusResponse.swift b/SodaLive/Sources/MyPage/Point/GetPointStatusResponse.swift new file mode 100644 index 0000000..7ae0a37 --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/GetPointStatusResponse.swift @@ -0,0 +1,10 @@ +// +// GetPointStatusResponse.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +struct GetPointStatusResponse: Decodable { + let point: Int +} diff --git a/SodaLive/Sources/MyPage/Point/PointStatusApi.swift b/SodaLive/Sources/MyPage/Point/PointStatusApi.swift new file mode 100644 index 0000000..7dcdcef --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/PointStatusApi.swift @@ -0,0 +1,55 @@ +// +// PointStatusApi.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +import Foundation +import Moya + +enum PointStatusApi { + case getPointStatus + case getPointRewardStatus + case getPointUseStatus +} + +extension PointStatusApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getPointStatus: + return "/point/status" + + case .getPointRewardStatus: + return "/point/status/reward" + + case .getPointUseStatus: + return "/point/status/use" + } + } + + var method: Moya.Method { + return .get + } + + var task: Moya.Task { + switch self { + case .getPointStatus: + return .requestPlain + + case .getPointRewardStatus, .getPointUseStatus: + return .requestParameters( + parameters: ["timezone": TimeZone.current.identifier], + encoding: URLEncoding.queryString + ) + } + } + + var headers: [String : String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/MyPage/Point/PointStatusRepository.swift b/SodaLive/Sources/MyPage/Point/PointStatusRepository.swift new file mode 100644 index 0000000..b3e6b55 --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/PointStatusRepository.swift @@ -0,0 +1,27 @@ +// +// PointStatusRepository.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +class PointStatusRepository { + private let api = MoyaProvider() + + func getPointStatus() -> AnyPublisher { + return api.requestPublisher(.getPointStatus) + } + + func getPointRewardStatus() -> AnyPublisher { + return api.requestPublisher(.getPointRewardStatus) + } + + func getPointUseStatus() -> AnyPublisher { + return api.requestPublisher(.getPointUseStatus) + } +} diff --git a/SodaLive/Sources/MyPage/Point/PointStatusView.swift b/SodaLive/Sources/MyPage/Point/PointStatusView.swift new file mode 100644 index 0000000..4736225 --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/PointStatusView.swift @@ -0,0 +1,135 @@ +// +// PointStatusView.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +import SwiftUI + +struct PointStatusView: View { + + let refresh: () -> Void + + @StateObject var viewModel = PointStatusViewModel() + + var body: some View { + BaseView(isLoading: $viewModel.isLoading) { + GeometryReader { proxy in + VStack(spacing: 0) { + DetailNavigationBar(title: "포인트 내역") { + AppState.shared.setAppStep(step: .main) + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 26.7) { + HStack(spacing: 6.7) { + Image("ic_point") + .resizable() + .frame(width: 26.7, height: 26.7) + + Text("\(viewModel.totalCan)") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.grayee) + } + } + .padding(.vertical, 13.3) + .frame(maxWidth: .infinity) + .padding(.horizontal, 13.3) + .background(Color.gray22) + .cornerRadius(16.7) + .padding(.top, 13.3) + + HStack(spacing: 0) { + VStack(spacing: 0) { + Spacer() + Text("받은내역") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(viewModel.currentTab == .reward ? .grayee : .gray77) + Spacer() + Rectangle() + .frame(height: 1) + .foregroundColor( + .button + .opacity(viewModel.currentTab == .reward ? 1 : 0) + ) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .onTapGesture { + if viewModel.currentTab != .reward { + viewModel.currentTab = .reward + } + } + + VStack(spacing: 0) { + Spacer() + Text("사용내역") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(viewModel.currentTab == .use ? .grayee : .gray77) + Spacer() + Rectangle() + .frame(height: 1) + .foregroundColor( + .button + .opacity(viewModel.currentTab == .use ? 1 : 0) + ) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .onTapGesture { + if viewModel.currentTab != .use { + viewModel.currentTab = .use + } + } + } + .padding(.top, 13.3) + + switch viewModel.currentTab { + case .reward: + PointRewardStatusView() + + case .use: + PointUseStatusView() + } + } + + Spacer() + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(.black) + .frame(width: proxy.size.width, height: 15.3) + } + } + .edgesIgnoringSafeArea(.bottom) + } + } + .popup(isPresented: $viewModel.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .padding(.horizontal, 6.7) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color.button) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.getPointStatus() + } + } +} + +#Preview { + PointStatusView {} +} diff --git a/SodaLive/Sources/MyPage/Point/PointStatusViewModel.swift b/SodaLive/Sources/MyPage/Point/PointStatusViewModel.swift new file mode 100644 index 0000000..63d157a --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/PointStatusViewModel.swift @@ -0,0 +1,143 @@ +// +// PointStatusViewModel.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +import Foundation +import Combine + +final class PointStatusViewModel: ObservableObject { + private let repository = PointStatusRepository() + private var subscription = Set() + + @Published var currentTab: CurrentTab = .reward + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var totalCan: Int = 0 + + @Published var useStatusItems: [GetPointUseStatusResponse] = [] + @Published var rewardStatusItems: [GetPointRewardStatusResponse] = [] + + func getPointStatus() { + isLoading = true + + repository.getPointStatus() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.totalCan = data.point + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func getPointRewardStatus() { + isLoading = true + + repository.getPointRewardStatus() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse<[GetPointRewardStatusResponse]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.rewardStatusItems.append(contentsOf: data) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func getPointUseStatus() { + isLoading = true + + repository.getPointUseStatus() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse<[GetPointUseStatusResponse]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.useStatusItems.append(contentsOf: data) + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + enum CurrentTab: String { + case reward, use + } +} diff --git a/SodaLive/Sources/MyPage/Point/Reward/GetPointRewardStatusResponse.swift b/SodaLive/Sources/MyPage/Point/Reward/GetPointRewardStatusResponse.swift new file mode 100644 index 0000000..d0581a3 --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/Reward/GetPointRewardStatusResponse.swift @@ -0,0 +1,12 @@ +// +// GetPointRewardStatusResponse.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +struct GetPointRewardStatusResponse: Decodable, Hashable { + let rewardPoint: String + let date: String + let method: String +} diff --git a/SodaLive/Sources/MyPage/Point/Reward/PointRewardStatusView.swift b/SodaLive/Sources/MyPage/Point/Reward/PointRewardStatusView.swift new file mode 100644 index 0000000..a0be4ae --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/Reward/PointRewardStatusView.swift @@ -0,0 +1,66 @@ +// +// PointRewardStatusView.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +import SwiftUI + +struct PointRewardStatusView: View { + + @StateObject var viewModel = PointStatusViewModel() + + var body: some View { + ZStack { + VStack(spacing: 13.3) { + ForEach(viewModel.rewardStatusItems, id: \.self) { item in + PointRewardStatusItemView(item: item) + } + } + .padding(.top, 13.3) + + if viewModel.isLoading { + LoadingView() + } + } + .onAppear { + viewModel.getPointRewardStatus() + } + } +} + +struct PointRewardStatusItemView: View { + + let item: GetPointRewardStatusResponse + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 6.7) { + Text(item.rewardPoint) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.grayee) + + Text(item.date) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(.gray77) + } + + Spacer() + + Text(item.method) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.grayee) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 16) + .background(Color.gray11) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(maxWidth: .infinity) + } +} + +#Preview { + PointRewardStatusView() +} diff --git a/SodaLive/Sources/MyPage/Point/Use/GetPointUseStatusResponse.swift b/SodaLive/Sources/MyPage/Point/Use/GetPointUseStatusResponse.swift new file mode 100644 index 0000000..feaf166 --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/Use/GetPointUseStatusResponse.swift @@ -0,0 +1,12 @@ +// +// GetPointUseStatusResponse.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +struct GetPointUseStatusResponse: Decodable, Hashable { + let title: String + let date: String + let point: Int +} diff --git a/SodaLive/Sources/MyPage/Point/Use/PointUseStatusView.swift b/SodaLive/Sources/MyPage/Point/Use/PointUseStatusView.swift new file mode 100644 index 0000000..016f357 --- /dev/null +++ b/SodaLive/Sources/MyPage/Point/Use/PointUseStatusView.swift @@ -0,0 +1,71 @@ +// +// PointUseStatusView.swift +// SodaLive +// +// Created by klaus on 5/20/25. +// + +import SwiftUI + +struct PointUseStatusView: View { + + @StateObject var viewModel = PointStatusViewModel() + + var body: some View { + ZStack { + VStack(spacing: 13.3) { + ForEach(viewModel.useStatusItems, id: \.self) { item in + PointUseStatusItemView(item: item) + } + } + .padding(.top, 13.3) + + if viewModel.isLoading { + LoadingView() + } + } + .onAppear { + viewModel.getPointUseStatus() + } + } +} + +struct PointUseStatusItemView: View { + + let item: GetPointUseStatusResponse + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 6.7) { + Text(item.title) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.grayee) + + Text(item.date) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(.gray77) + } + + Spacer() + + Text("\(item.point)") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(.grayee) + + Image("ic_point") + .resizable() + .frame(width: 26.7, height: 26.7) + .padding(.leading, 6.7) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 16) + .background(Color.gray11) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(maxWidth: .infinity) + } +} + +#Preview { + PointUseStatusView() +}