diff --git a/SodaLive/Sources/App/AppStep.swift b/SodaLive/Sources/App/AppStep.swift index 54fc4b7..11fa49d 100644 --- a/SodaLive/Sources/App/AppStep.swift +++ b/SodaLive/Sources/App/AppStep.swift @@ -5,7 +5,7 @@ // Created by klaus on 2023/08/09. // -import Foundation +import StoreKit enum AppStep { case splash @@ -39,4 +39,12 @@ enum AppStep { case notificationSettings case signOut + + case canStatus(refresh: () -> Void) + + case canCharge(refresh: () -> Void, afterCompletionToGoBack: Bool = false) + + case canPayment(canProduct: SKProduct, refresh: () -> Void, afterCompletionToGoBack: Bool = false) + + case canPgPayment(canResponse: GetCanResponse, refresh: () -> Void, afterCompletionToGoBack: Bool = false) } diff --git a/SodaLive/Sources/ContentView.swift b/SodaLive/Sources/ContentView.swift index 579ca1f..2218428 100644 --- a/SodaLive/Sources/ContentView.swift +++ b/SodaLive/Sources/ContentView.swift @@ -62,6 +62,18 @@ struct ContentView: View { case .signOut: SignOutView() + case .canStatus(let refresh): + CanStatusView(refresh: refresh) + + case .canCharge(let refresh, let afterCompletionToGoBack): + CanChargeView(refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + + case .canPayment(let canProduct, let refresh, let afterCompletionToGoBack): + CanPaymentView(canProduct: canProduct, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + + case .canPgPayment(let canResponse, let refresh, let afterCompletionToGoBack): + CanPgPaymentView(canResponse: canResponse, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + default: EmptyView() .frame(width: 0, height: 0, alignment: .topLeading) diff --git a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift index 7713f03..a51a09d 100644 --- a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift +++ b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift @@ -10,7 +10,7 @@ import Foundation enum UserDefaultsKey: String, CaseIterable { case auth case role - case coin + case can case token case email case userId diff --git a/SodaLive/Sources/IAP/StoreManager.swift b/SodaLive/Sources/IAP/StoreManager.swift new file mode 100644 index 0000000..868ff7d --- /dev/null +++ b/SodaLive/Sources/IAP/StoreManager.swift @@ -0,0 +1,176 @@ +// +// StoreManager.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// +// Ref. +// https://www.youtube.com/watch?v=qyKmpr9EjwU +// https://nicgoon.tistory.com/205 +// https://charlie-choi.tistory.com/241 +// https://eunjo-princess.tistory.com/20 +// https://developer.apple.com/documentation/storekit/in-app_purchase/original_api_for_in-app_purchase/validating_receipts_with_the_app_store#//apple_ref/doc/uid/TP40010573-CH104-SW2 +// https://velog.io/@givepro91/%EC%9D%B8%EC%95%B1%EA%B2%B0%EC%A0%9C-%EC%84%9C%EB%B2%84-%EA%B0%9C%EB%B0%9C +// https://twih1203.medium.com/swift-%EC%9D%B8%EC%95%B1-%EA%B2%B0%EC%A0%9C-%EA%B5%AC%ED%98%84-ff4b2d20a260 +// + +import Foundation +import StoreKit + +class StoreManager: NSObject, ObservableObject { + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var products = [SKProduct]() + + var onSuccessPayment: ((String, Int) -> Void)? + var chargeId: Int! + + var request: SKProductsRequest! + + func getProducts() { + isLoading = true + products.removeAll() + + let request = SKProductsRequest(productIdentifiers: [ + "\(Bundle.main.bundleIdentifier!).can_100" + ]) + request.delegate = self + request.start() + } + + func payment(product: SKProduct, chargeId: Int) { + isLoading = true + self.chargeId = chargeId + let payment = SKPayment(product: product) + SKPaymentQueue.default().add(self) + SKPaymentQueue.default().add(payment) + } +} + +extension StoreManager: SKProductsRequestDelegate { + func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + if !response.products.isEmpty { + let products = response.products.sorted { $0.price.compare($1.price) == .orderedAscending } + + for product in products { + DispatchQueue.main.async { + self.products.append(product) + } + } + } + + DispatchQueue.main.async { [unowned self] in + self.isLoading = false + } + self.request = nil + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + self.request = nil + DEBUG_LOG("상품불러오기 실패: \(error)") + DispatchQueue.main.async { [unowned self] in + self.isLoading = false + errorMessage = "상품을 불러오지 못했습니다.\n다시 시도해 주세요." + self.isShowPopup = true + } + } +} + +extension StoreManager: SKPaymentTransactionObserver { + func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + for transaction in transactions { + switch(transaction.transactionState) { + case .purchasing: + DEBUG_LOG("결제 진행 중...") + break + + case .purchased: + isLoading = false + DEBUG_LOG("결제 완료") + complete(transaction: transaction) + break + + case .failed: + isLoading = false + DEBUG_LOG("결제 실패") + fail(transaction: transaction) + break + + case .deferred: + isLoading = false + DEBUG_LOG("아이폰이 잠김 등의 이유로 결제를 진행하지 못했습니다.") + errorMessage = "아이폰이 잠김 등의 이유로 결제를 진행하지 못했습니다." + isShowPopup = true + + SKPaymentQueue.default().finishTransaction(transaction) + SKPaymentQueue.default().remove(self) + break + + case .restored: + isLoading = false + DEBUG_LOG("상품 검증을 하였습니다.") + errorMessage = "상품 검증을 하였습니다." + isShowPopup = true + + SKPaymentQueue.default().finishTransaction(transaction) + SKPaymentQueue.default().remove(self) + break + + @unknown default: + isLoading = false + DEBUG_LOG("알 수 없는 오류가 발생했습니다.") + errorMessage = "알 수 없는 오류가 발생했습니다." + isShowPopup = true + + SKPaymentQueue.default().finishTransaction(transaction) + SKPaymentQueue.default().remove(self) + + fatalError() + } + } + } + + private func complete(transaction: SKPaymentTransaction) { + if let onSuccessPayment = onSuccessPayment { + if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, + FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { + + do { + let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) + let receiptString = receiptData.base64EncodedString(options: []) + onSuccessPayment(receiptString, chargeId) + + SKPaymentQueue.default().finishTransaction(transaction) + SKPaymentQueue.default().remove(self) + } + catch { + DEBUG_LOG("영수증 데이터 읽기 실패: " + error.localizedDescription) + fail(transaction: transaction) + } + } else { + DEBUG_LOG("영수증 데이터 읽기 실패") + fail(transaction: transaction) + } + } else { + fail(transaction: transaction) + } + } + + private func fail(transaction: SKPaymentTransaction) { + if let transactionError = transaction.error as NSError?, + let localizedDescription = transaction.error?.localizedDescription, + transactionError.code != SKError.paymentCancelled.rawValue { + DEBUG_LOG("Transaction Error: \(localizedDescription)") + } + + DispatchQueue.main.async { [unowned self] in + errorMessage = "결제를 진행하지 못했습니다.\n다시 시도해 주세요." + isShowPopup = true + } + + SKPaymentQueue.default().finishTransaction(transaction) + SKPaymentQueue.default().remove(self) + } +} diff --git a/SodaLive/Sources/MyPage/Can/CanApi.swift b/SodaLive/Sources/MyPage/Can/CanApi.swift new file mode 100644 index 0000000..9febb81 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/CanApi.swift @@ -0,0 +1,109 @@ +// +// CanApi.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Moya + +enum CanApi { + case getCanStatus + case getCanChargeStatus + case getCanUseStatus + case getCans + + case chargeCan(request: CanChargeRequest) + case verify(request: CanVerifyRequest) + + case pgChargeCan(request: PgChargeRequest) + case pgVerify(request: PgVerifyRequest) +} + +extension CanApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getCanStatus: + return "/can/status" + + case .getCanChargeStatus: + return "/can/status/charge" + + case .getCanUseStatus: + return "/can/status/use" + + case .getCans: + return "/can" + + case .chargeCan: + return "/charge/apple" + + case .verify: + return "/charge/apple/verify" + + case .pgChargeCan: + return "/charge" + + case .pgVerify: + return "/charge/v2/verify" + } + } + + var method: Moya.Method { + switch self { + case .getCanStatus, .getCanChargeStatus, .getCanUseStatus, .getCans: + return .get + + case .chargeCan, .verify, .pgChargeCan, .pgVerify: + return .post + } + } + + var task: Task { + switch self { + case .getCanStatus: + return .requestParameters( + parameters: ["container" : "ios"], + encoding: URLEncoding.queryString + ) + + case .getCanChargeStatus, .getCanUseStatus: + return .requestParameters( + parameters: [ + "timezone": TimeZone.current.identifier, + "page": 0, + "size": 30, + "container": "ios" + ], + encoding: URLEncoding.queryString + ) + + case .getCans: + return .requestPlain + + case .chargeCan(let request): + return .requestJSONEncodable(request) + + case .verify(let request): + return .requestJSONEncodable(request) + + case .pgChargeCan(let request): + return .requestJSONEncodable(request) + + case .pgVerify(let request): + return .requestJSONEncodable(request) + } + } + + var headers: [String : String]? { + switch self { + default: + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } + } +} diff --git a/SodaLive/Sources/MyPage/Can/CanRepository.swift b/SodaLive/Sources/MyPage/Can/CanRepository.swift new file mode 100644 index 0000000..b5e4f08 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/CanRepository.swift @@ -0,0 +1,48 @@ +// +// CanRepository.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import CombineMoya +import Combine +import Moya + +final class CanRepository { + private let api = MoyaProvider() + + func getCanStatus() -> AnyPublisher { + return api.requestPublisher(.getCanStatus) + } + + func getCanChargeStatus() -> AnyPublisher { + return api.requestPublisher(.getCanChargeStatus) + } + + func getCanUseStatus() -> AnyPublisher { + return api.requestPublisher(.getCanUseStatus) + } + + func chargeCan(title: String, chargeCan: Int, price: Double, locale: String, paymentGateway: PaymentGateway = .APPLE_IAP) -> AnyPublisher { + return api.requestPublisher(.chargeCan(request: CanChargeRequest(title: title, chargeCan: chargeCan, paymentGateway: paymentGateway, price: price, locale: locale))) + } + + func verify(receiptString: String, chargeId: Int) -> AnyPublisher { + return api.requestPublisher(.verify(request: CanVerifyRequest(receiptString: receiptString, chargeId: chargeId))) + } + + func pgChargeCan(canId: Int) -> AnyPublisher { + return api.requestPublisher(.pgChargeCan(request: PgChargeRequest(canId: canId, paymentGateway: .PG))) + } + + func pgVerify(receiptId: String, orderId: String) -> AnyPublisher { + return api.requestPublisher(.pgVerify(request: PgVerifyRequest(receiptId: receiptId, orderId: orderId))) + } + + func getCans() -> AnyPublisher { + return api.requestPublisher(.getCans) + } +} + diff --git a/SodaLive/Sources/MyPage/Can/Charge/CanChargeRequest.swift b/SodaLive/Sources/MyPage/Can/Charge/CanChargeRequest.swift new file mode 100644 index 0000000..8418223 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Charge/CanChargeRequest.swift @@ -0,0 +1,40 @@ +// +// CanChargeRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct CanChargeRequest: Encodable { + let title: String + let chargeCan: Int + let paymentGateway: PaymentGateway + let price: Double + let locale: String +} + +struct PgChargeRequest: Encodable { + let canId: Int + let paymentGateway: PaymentGateway +} + +struct CanChargeResponse: Decodable { + let chargeId: Int +} + +struct CanVerifyRequest: Encodable { + let receiptString: String + let chargeId: Int +} + +struct PgVerifyRequest: Encodable { + let receiptId: String + let orderId: String + + enum CodingKeys : String, CodingKey { + case receiptId = "receipt_id" + case orderId = "order_id" + } +} diff --git a/SodaLive/Sources/MyPage/Can/Charge/CanChargeView.swift b/SodaLive/Sources/MyPage/Can/Charge/CanChargeView.swift new file mode 100644 index 0000000..16c1f35 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Charge/CanChargeView.swift @@ -0,0 +1,221 @@ +// +// CanChargeView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import StoreKit + +enum CanChargeCurrentTab: String { + case iap, pg +} + +struct CanChargeView: View { + + @StateObject var storeManager = StoreManager() + @StateObject var viewModel = CanChargeViewModel() + + @State var currentTab: CanChargeCurrentTab = .iap + + let refresh: () -> Void + let afterCompletionToGoBack: Bool + + var body: some View { + BaseView(isLoading: $storeManager.isLoading) { + VStack(spacing: 13.3) { + DetailNavigationBar(title: "충전하기") + + if UserDefaults.bool(forKey: .auth) { + CanChargeTabView(currentTab: $currentTab) + } + + if UserDefaults.bool(forKey: .auth) && currentTab == .pg { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 13.3) { + ForEach(viewModel.cans, id: \.self) { item in + CanPgItemView(item: item) + .onTapGesture { + AppState.shared.setAppStep( + step: .canPgPayment(canResponse: item, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + ) + } + } + } + .onAppear { + viewModel.getCans() + } + } + } else { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 13.3) { + ForEach(storeManager.products, id: \.self) { item in + CanItemView(item: item) + .onTapGesture { + AppState.shared.setAppStep( + step: .canPayment(canProduct: item, refresh: refresh, afterCompletionToGoBack: afterCompletionToGoBack) + ) + } + } + } + .onAppear { + storeManager.getProducts() + } + } + } + } + } + .popup(isPresented: $storeManager.isShowPopup, type: .toast, position: .top, autohideIn: 1) { + GeometryReader { geo in + HStack { + Spacer() + Text(storeManager.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(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } +} + +struct CanItemView: View { + + let item: SKProduct + + var body: some View { + HStack(spacing: 0) { + Text(item.localizedTitle) + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("\(NumberFormatter.localizedString(from: item.price, number: .currency))") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 23.3) + .background(Color(hex: "111111")) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + } +} + +struct CanPgItemView: View { + let item: GetCanResponse + + var body: some View { + HStack(spacing: 0) { + Text(item.title) + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("\(item.price) 원") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 23.3) + .background(Color(hex: "111111")) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + } +} + +struct CanChargeTabView: View { + + @Binding var currentTab: CanChargeCurrentTab + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + let tabWidth = screenSize().width / 2 + + CanChargeTab( + title: "인 앱 결제", + action: { + if currentTab != .iap { + currentTab = .iap + } + }, + color: { + currentTab == .iap ? + Color(hex: "eeeeee") : + Color(hex: "777777") + }, + width: tabWidth, + isShowDivider: { currentTab == .iap } + ) + + CanChargeTab( + title: "PG", + action: { + if currentTab != .pg { + currentTab = .pg + } + }, + color: { + currentTab == .pg ? + Color(hex: "eeeeee") : + Color(hex: "777777") + }, + width: tabWidth, + isShowDivider: { currentTab == .pg } + ) + } + + Rectangle() + .frame(width: screenSize().width, height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + } + } +} + +struct CanChargeTab: View { + let title: String + let action: () -> Void + let color: () -> Color + let width: CGFloat + let isShowDivider: () -> Bool + + var body: some View { + Button(action: action) { + VStack(spacing: 0) { + Spacer() + + Text(title) + .font(.custom(Font.medium.rawValue, size: 16.7)) + .foregroundColor(color()) + .frame(width: width) + + Spacer() + + Rectangle() + .frame(width: width, height: 3) + .foregroundColor(Color(hex: "9970ff").opacity(isShowDivider() ? 1 : 0)) + } + .frame(height: 50) + } + } +} + +struct CanChargeView_Previews: PreviewProvider { + static var previews: some View { + CanChargeView(refresh: {}, afterCompletionToGoBack: false) + } +} diff --git a/SodaLive/Sources/MyPage/Can/Charge/CanChargeViewModel.swift b/SodaLive/Sources/MyPage/Can/Charge/CanChargeViewModel.swift new file mode 100644 index 0000000..b5e5228 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Charge/CanChargeViewModel.swift @@ -0,0 +1,58 @@ +// +// CanChargeViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +final class CanChargeViewModel: ObservableObject { + + private let repository = CanRepository() + private var subscription = Set() + + @Published var cans = [GetCanResponse]() + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + func getCans() { + isLoading = true + repository.getCans() + .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<[GetCanResponse]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.cans.removeAll() + self.cans.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) + } +} diff --git a/SodaLive/Sources/MyPage/Can/GetCanResponse.swift b/SodaLive/Sources/MyPage/Can/GetCanResponse.swift new file mode 100644 index 0000000..5faa378 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/GetCanResponse.swift @@ -0,0 +1,16 @@ +// +// GetCanResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetCanResponse: Decodable, Hashable { + let id: Int + let title: String + let can: Int + let rewardCan: Int + let price: Int +} diff --git a/SodaLive/Sources/MyPage/Can/Payment/CanPaymentView.swift b/SodaLive/Sources/MyPage/Can/Payment/CanPaymentView.swift new file mode 100644 index 0000000..02caf99 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/CanPaymentView.swift @@ -0,0 +1,249 @@ +// +// CanPaymentView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import StoreKit + +struct CanPaymentView: View { + + @StateObject var viewModel = CanPaymentViewModel() + @StateObject var storeManager = StoreManager() + + let canProduct: SKProduct + let refresh: () -> Void + let afterCompletionToGoBack: Bool + + init(canProduct: SKProduct, refresh: @escaping () -> Void, afterCompletionToGoBack: Bool) { + self.canProduct = canProduct + self.refresh = refresh + self.afterCompletionToGoBack = afterCompletionToGoBack + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + GeometryReader { proxy in + VStack(spacing: 0) { + DetailNavigationBar(title: "결제하기") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Image("ic_can") + .resizable() + .scaledToFill() + .frame(width: 26.7, height: 26.7, alignment: .top) + + Text(canProduct.localizedTitle) + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.leading, 13.3) + + Spacer() + + Text("\(NumberFormatter.localizedString(from: canProduct.price, number: .currency))") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 23.3) + .background(Color(hex: "222222")) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + .padding(.top, 13.3) + + HStack(spacing: 6.7) { + Image(viewModel.isTermsAgree ? "btn_select_checked" : "btn_select_normal") + .resizable() + .frame(width: 20, height: 20) + + Text("구매조건 확인 및 결제 진행 동의") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + .padding(.top, 13.3) + .onTapGesture { + viewModel.isTermsAgree.toggle() + } + + VStack(spacing: 6.7) { + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("충전된 코인의 유효기간은 충전 후 5년 입니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + .padding(.top, 26.7) + + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("결제 취소는 결제 후 7일 이내에만 할 수 있습니다.\n단, 코인의 일부를 사용하면 결제 취소를 할 수 없습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("광고성 이벤트 등 회사가 무료로 지급한 포인트는 환불되지 않습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("자세한 내용은 요즘라이브 이용약관에서 확인할 수 있습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + } + } + } + + Spacer() + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + Text("결제금액") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + HStack(spacing: 0) { + Text("\(NumberFormatter.localizedString(from: canProduct.price, number: .currency))") + .font(.custom(Font.bold.rawValue, size: 23.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + } + + Spacer() + + Text("결제하기") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(minWidth: 200) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .onTapGesture { + if viewModel.isTermsAgree { + viewModel.chargeCan(canProduct: canProduct, paymentGateway: .APPLE_IAP) { product, chargeId in + storeManager.payment(product: product, chargeId: chargeId) + } + } else { + viewModel.errorMessage = "결제진행에 동의하셔야 결제가 가능합니다." + viewModel.isShowPopup = true + } + } + } + .padding(.leading, 22) + .padding(.trailing, 13.3) + .padding(.vertical, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .edgesIgnoringSafeArea(.bottom) + } + + if viewModel.isLoading || storeManager.isLoading { + LoadingView() + } + } + .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(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .popup(isPresented: $storeManager.isShowPopup, type: .toast, position: .top, autohideIn: 2) { + GeometryReader { geo in + HStack { + Spacer() + Text(storeManager.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(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + storeManager.onSuccessPayment = { receiptString, chargeId in + viewModel.verifyPayment(receiptString: receiptString, chargeId: chargeId) { + self.refresh() + + if afterCompletionToGoBack { + AppState.shared.back() + } else { + AppState.shared.setAppStep(step: .canStatus(refresh: refresh)) + } + + let chargeCan = Int(canProduct.localizedDescription) ?? 0 + let can = UserDefaults.int(forKey: .can) + UserDefaults.set(can + chargeCan, forKey: .can) + } + } + } + } +} + +struct CanPaymentView_Previews: PreviewProvider { + static var previews: some View { + CanPaymentView( + canProduct: SKProduct(), + refresh: {}, + afterCompletionToGoBack: false + ) + } +} diff --git a/SodaLive/Sources/MyPage/Can/Payment/CanPaymentViewModel.swift b/SodaLive/Sources/MyPage/Can/Payment/CanPaymentViewModel.swift new file mode 100644 index 0000000..ef96b6f --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/CanPaymentViewModel.swift @@ -0,0 +1,101 @@ +// +// CanPaymentViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine +import StoreKit + +final class CanPaymentViewModel: ObservableObject { + + private let repository = CanRepository() + private var subscription = Set() + + @Published var isTermsAgree = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + func chargeCan(canProduct: SKProduct, paymentGateway: PaymentGateway, onSuccess: @escaping (SKProduct, Int) -> Void) { + isLoading = true + repository.chargeCan( + title: canProduct.localizedTitle, + chargeCan: Int(canProduct.localizedDescription) ?? 0, + price: canProduct.price.doubleValue, + locale: canProduct.priceLocale.identifier, + paymentGateway: paymentGateway + ) + .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 { + onSuccess(canProduct, data.chargeId) + } 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 verifyPayment(receiptString: String, chargeId: Int, onSuccess: @escaping () -> Void) { + isLoading = true + repository.verify(receiptString: receiptString, chargeId: chargeId) + .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(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + onSuccess() + } 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) + } +} diff --git a/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift new file mode 100644 index 0000000..437bf12 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift @@ -0,0 +1,310 @@ +// +// CanPgPaymentView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI +import Bootpay +import BootpayUI + +struct CanPgPaymentView: View { + + @StateObject var viewModel = CanPgPaymentViewModel() + + let canResponse: GetCanResponse + let refresh: () -> Void + let afterCompletionToGoBack: Bool + + init(canResponse: GetCanResponse, refresh: @escaping () -> Void, afterCompletionToGoBack: Bool) { + self.canResponse = canResponse + self.refresh = refresh + self.afterCompletionToGoBack = afterCompletionToGoBack + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if viewModel.isShowPamentView { + BootpayUI(payload: viewModel.payload, requestType: BootpayRequest.TYPE_PAYMENT) + .onConfirm { + DEBUG_LOG("onConfirm: \($0)") + return true + } + .onCancel { + DEBUG_LOG("onCancel: \($0)") + } + .onError { + DEBUG_LOG("onError: \($0)") + viewModel.isShowPamentView = false + viewModel.errorMessage = "결제 중 오류가 발생했습니다." + viewModel.isShowPopup = true + } + .onDone { + DEBUG_LOG("onDone: \($0)") + viewModel.verifyPayment($0) { + self.refresh() + + if afterCompletionToGoBack { + AppState.shared.back() + } else { + AppState.shared.setAppStep(step: .canStatus(refresh: refresh)) + } + + let can = UserDefaults.int(forKey: .can) + UserDefaults.set(can + canResponse.can + canResponse.rewardCan, forKey: .can) + } + } + .onClose { + DEBUG_LOG("onClose") + viewModel.isShowPamentView = false + } + } else { + GeometryReader { proxy in + VStack(spacing: 0) { + DetailNavigationBar(title: "결제하기") + + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + HStack(spacing: 0) { + Image("ic_can") + .resizable() + .scaledToFill() + .frame(width: 26.7, height: 26.7, alignment: .top) + + Text(canResponse.title) + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.leading, 13.3) + + Spacer() + + Text("\(canResponse.price) 원") + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 23.3) + .background(Color(hex: "222222")) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + .padding(.top, 13.3) + + Text("결제 수단 선택") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + .frame(width: screenSize().width - 26.7, alignment: .leading) + .padding(.top, 26.7) + + + HStack(spacing: 13.3) { + Text("카드") + .font(.custom( viewModel.paymentMethod == .card ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: viewModel.paymentMethod == .card ? "9970ff" : "eeeeee")) + .frame(width: (screenSize().width - 40) / 2) + .padding(.vertical, 16.7) + .background( + Color(hex: viewModel.paymentMethod == .card ? "9970ff" : "232323") + .opacity(viewModel.paymentMethod == .card ? 0.3 : 1) + ) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1) + .foregroundColor(Color(hex: viewModel.paymentMethod == .card ? "9970ff" : "777777")) + ) + .onTapGesture { + if viewModel.paymentMethod != .card { + viewModel.paymentMethod = .card + } + } + + + Text("계좌이체") + .font(.custom( viewModel.paymentMethod == .bank ? Font.bold.rawValue : Font.medium.rawValue, size: 16.7)) + .foregroundColor(Color(hex: viewModel.paymentMethod == .bank ? "9970ff" : "eeeeee")) + .frame(width: (screenSize().width - 40) / 2) + .padding(.vertical, 16.7) + .background( + Color(hex: viewModel.paymentMethod == .bank ? "9970ff" : "232323") + .opacity(viewModel.paymentMethod == .bank ? 0.3 : 1) + ) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(lineWidth: 1) + .foregroundColor(Color(hex: viewModel.paymentMethod == .bank ? "9970ff" : "777777")) + ) + .onTapGesture { + if viewModel.paymentMethod != .bank { + viewModel.paymentMethod = .bank + } + } + } + .frame(width: screenSize().width - 26.7) + .padding(.top, 16.7) + + HStack(spacing: 6.7) { + Image(viewModel.isTermsAgree ? "btn_select_checked" : "btn_select_normal") + .resizable() + .frame(width: 20, height: 20) + + Text("구매조건 확인 및 결제 진행 동의") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + .padding(.top, 16.7) + .onTapGesture { + viewModel.isTermsAgree.toggle() + } + + VStack(spacing: 6.7) { + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("충전된 캔의 유효기간은 충전 후 5년 입니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + .padding(.top, 26.7) + + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("결제 취소는 결제 후 7일 이내에만 할 수 있습니다.\n단, 캔의 일부를 사용하면 결제 취소를 할 수 없습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("광고성 이벤트 등 회사가 무료로 지급한 포인트는 환불되지 않습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + + HStack(alignment: .top, spacing: 0) { + Text("- ") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + Text("자세한 내용은 요즘라이브 이용약관에서 확인할 수 있습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + .fixedSize(horizontal: false, vertical: true) + } + .frame(width: screenSize().width - 53.4, alignment: .leading) + } + } + } + + Spacer() + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + Text("결제금액") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + HStack(spacing: 0) { + Text("\(canResponse.price) 원") + .font(.custom(Font.bold.rawValue, size: 23.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + } + + Spacer() + + Text("결제하기") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(minWidth: 200) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .onTapGesture { + if viewModel.paymentMethod == nil { + viewModel.errorMessage = "결제수단을 선택해 주세요." + viewModel.isShowPopup = true + } else if !viewModel.isTermsAgree { + viewModel.errorMessage = "결제진행에 동의하셔야 결제가 가능합니다." + viewModel.isShowPopup = true + } else { + viewModel.chargeCan(canId: canResponse.id) { + viewModel.payload.orderName = canResponse.title + viewModel.payload.price = Double(canResponse.price) + viewModel.payload.taxFree = 0 + + viewModel.isShowPamentView = true + } + } + } + } + .padding(.leading, 22) + .padding(.trailing, 13.3) + .padding(.vertical, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: proxy.size.width, height: 15.3) + } + } + .edgesIgnoringSafeArea(.bottom) + } + } + + if viewModel.isLoading { + LoadingView() + } + } + .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(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } +} + +struct CanPgPaymentView_Previews: PreviewProvider { + static var previews: some View { + CanPgPaymentView( + canResponse: GetCanResponse(id: 1, title: "300 캔", can: 300, rewardCan: 0, price: 7500), + refresh: {}, + afterCompletionToGoBack: false + ) + } +} diff --git a/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift new file mode 100644 index 0000000..6f3bc4c --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift @@ -0,0 +1,123 @@ +// +// CanPgPaymentViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation +import Combine + +import Bootpay + +enum PaymentMethod: String { + case card = "디지털카드" + case bank = "디지털계좌이체" +} + +final class CanPgPaymentViewModel: ObservableObject { + + private let repository = CanRepository() + private var subscription = Set() + + @Published var isTermsAgree = false + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var isShowPamentView = false + @Published var paymentMethod: PaymentMethod? = nil + + let payload = Payload() + + func chargeCan(canId: Int, onSuccess: @escaping () -> Void) { + isLoading = true + repository.pgChargeCan(canId: canId) + .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 { + payload.applicationId = BOOTPAY_APP_ID + payload.pg = "웰컴페이먼츠" + payload.orderId = "\(data.chargeId)" + payload.method = paymentMethod!.rawValue + + onSuccess() + } 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 verifyPayment(_ data: [String: Any], onSuccess: @escaping () -> Void) { + isLoading = true + + let _data = data["data"] as? [String: Any] + + if let data = _data { + let receiptId = data["receipt_id"] as! String + let orderId = data["order_id"] as! String + + repository.pgVerify(receiptId: receiptId, orderId: orderId) + .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(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + onSuccess() + } 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) + } else { + isLoading = false + errorMessage = "본인인증 중 오류가 발생했습니다." + isShowPopup = true + } + } +} diff --git a/SodaLive/Sources/MyPage/Can/Payment/PaymentGateway.swift b/SodaLive/Sources/MyPage/Can/Payment/PaymentGateway.swift new file mode 100644 index 0000000..d1c9d01 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Payment/PaymentGateway.swift @@ -0,0 +1,12 @@ +// +// PaymentGateway.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +enum PaymentGateway: String, Codable { + case PG, APPLE_IAP +} diff --git a/SodaLive/Sources/MyPage/Can/Status/CanChargeStatusView.swift b/SodaLive/Sources/MyPage/Can/Status/CanChargeStatusView.swift new file mode 100644 index 0000000..0b5297f --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/CanChargeStatusView.swift @@ -0,0 +1,67 @@ +// +// CanChargeStatusView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct CanChargeStatusView: View { + + @StateObject var viewModel = CanStatusViewModel() + + var body: some View { + ZStack { + VStack(spacing: 13.3) { + ForEach(viewModel.chargeStatusItems, id: \.self) { item in + CanChargeStatusItemView(item: item) + } + } + + if viewModel.isLoading { + LoadingView() + } + } + .onAppear { + viewModel.getCanChargeStatus() + } + } +} + +struct CanChargeStatusItemView: View { + + let item: GetCanChargeStatusResponseItem + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 6.7) { + Text(item.canTitle) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(item.date) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + } + + Spacer() + + Text(item.chargeMethod) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 16) + .background(Color(hex: "111111")) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + } +} + +struct CanChargeStatusView_Previews: PreviewProvider { + static var previews: some View { + CanChargeStatusView() + } +} diff --git a/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift b/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift new file mode 100644 index 0000000..6553746 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift @@ -0,0 +1,199 @@ +// +// CanStatusView.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import SwiftUI + +struct CanStatusView: View { + + @StateObject var viewModel = CanStatusViewModel() + + let refresh: () -> Void + + 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_can") + .resizable() + .frame(width: 26.7, height: 26.7) + + Text("\(viewModel.totalCan) 캔") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + + HStack(spacing: 26.7) { + VStack(spacing: 10) { + Text("결제 캔") + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + HStack(alignment: .bottom, spacing: 3.3) { + Text("\(viewModel.chargeCan)") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(" 캔") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "bbbbbb")) + } + } + .padding(.horizontal, 6.7) + + Rectangle() + .frame(width: 1, height: 26.7) + .foregroundColor(Color(hex: "909090").opacity(0.5)) + + VStack(spacing: 10) { + Text("리워드 캔") + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + + HStack(alignment: .bottom, spacing: 3.3) { + Text("\(viewModel.rewardCan)") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(" 캔") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "bbbbbb")) + } + } + .padding(.horizontal, 6.7) + } + } + .padding(.vertical, 13.3) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "222222")) + .cornerRadius(16.7) + .padding(.top, 13.3) + + HStack(spacing: 0) { + VStack(spacing: 0) { + Spacer() + Text("충전내역") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor( + Color(hex: viewModel.currentTab == .charge ? "eeeeee" : "777777") + ) + Spacer() + Rectangle() + .frame(height: 1) + .foregroundColor( + Color(hex: "fdca2f") + .opacity(viewModel.currentTab == .charge ? 1 : 0) + ) + } + .frame(width: screenSize().width / 2, height: 50) + .onTapGesture { + if viewModel.currentTab != .charge { + viewModel.currentTab = .charge + } + } + + VStack(spacing: 0) { + Spacer() + Text("사용내역") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor( + Color(hex: viewModel.currentTab == .use ? "eeeeee" : "777777") + ) + Spacer() + Rectangle() + .frame(height: 1) + .foregroundColor( + Color(hex: "fdca2f") + .opacity(viewModel.currentTab == .use ? 1 : 0) + ) + } + .frame(width: screenSize().width / 2, height: 50) + .onTapGesture { + if viewModel.currentTab != .use { + viewModel.currentTab = .use + } + } + } + .padding(.top, 13.3) + + switch viewModel.currentTab { + case .charge: + CanChargeStatusView() + + case .use: + CanUseStatusView() + } + } + + Spacer() + + HStack(spacing: 6.7) { + Image("ic_can") + .resizable() + .frame(width: 26.7, height: 26.7) + + Text("충전하기") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color.black) + } + .padding(.vertical, 16) + .frame(width: screenSize().width - 26.7) + .background(Color(hex: "fdca2f")) + .cornerRadius(10) + .padding(.vertical, 13.7) + .frame(width: screenSize().width) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + .onTapGesture { + AppState.shared.setAppStep(step: .canCharge(refresh: refresh)) + } + + if proxy.safeAreaInsets.bottom > 0 { + Rectangle() + .foregroundColor(Color(hex: "222222")) + .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(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + .onAppear { + viewModel.getCanStatus() + } + } +} + +struct CanStatusView_Previews: PreviewProvider { + static var previews: some View { + CanStatusView {} + } +} diff --git a/SodaLive/Sources/MyPage/Can/Status/CanStatusViewModel.swift b/SodaLive/Sources/MyPage/Can/Status/CanStatusViewModel.swift new file mode 100644 index 0000000..6bd6462 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/CanStatusViewModel.swift @@ -0,0 +1,147 @@ +// +// CanStatusViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/10. +// + +import Foundation +import Combine + +final class CanStatusViewModel: ObservableObject { + private let repository = CanRepository() + private var subscription = Set() + + @Published var currentTab: CurrentTab = .charge + + @Published var errorMessage = "" + @Published var isShowPopup = false + @Published var isLoading = false + + @Published var totalCan: Int = 0 + @Published var rewardCan: Int = 0 + @Published var chargeCan: Int = 0 + + @Published var useStatusItems: [GetCanUseStatusResponseItem] = [] + @Published var chargeStatusItems: [GetCanChargeStatusResponseItem] = [] + + func getCanStatus() { + isLoading = true + + repository.getCanStatus() + .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.chargeCan = data.chargeCan + self.rewardCan = data.rewardCan + self.totalCan = data.chargeCan + data.rewardCan + } 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 getCanChargeStatus() { + isLoading = true + + repository.getCanChargeStatus() + .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<[GetCanChargeStatusResponseItem]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.chargeStatusItems.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 getCanUseStatus() { + isLoading = true + + repository.getCanUseStatus() + .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<[GetCanUseStatusResponseItem]>.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 charge, use + } +} diff --git a/SodaLive/Sources/MyPage/Can/Status/CanUseStatusView.swift b/SodaLive/Sources/MyPage/Can/Status/CanUseStatusView.swift new file mode 100644 index 0000000..ff7b483 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/CanUseStatusView.swift @@ -0,0 +1,72 @@ +// +// CanUseStatusView.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import SwiftUI + +struct CanUseStatusView: View { + + @StateObject var viewModel = CanStatusViewModel() + + var body: some View { + ZStack { + VStack(spacing: 13.3) { + ForEach(viewModel.useStatusItems, id: \.self) { item in + CanUseStatusItemView(item: item) + } + } + + if viewModel.isLoading { + LoadingView() + } + } + .onAppear { + viewModel.getCanUseStatus() + } + } +} + +struct CanUseStatusItemView: View { + + let item: GetCanUseStatusResponseItem + + 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(Color(hex: "eeeeee")) + + Text(item.date) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "777777")) + } + + Spacer() + + Text("\(item.can)") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Image("ic_can") + .resizable() + .frame(width: 26.7, height: 26.7) + .padding(.leading, 6.7) + } + .padding(.horizontal, 13.3) + .padding(.vertical, 16) + .background(Color(hex: "111111")) + .cornerRadius(16.7) + .padding(.horizontal, 13.3) + .frame(width: screenSize().width) + } +} + +struct CanUseStatusView_Previews: PreviewProvider { + static var previews: some View { + CanUseStatusView() + } +} diff --git a/SodaLive/Sources/MyPage/Can/Status/GetCanChargeStatusResponseItem.swift b/SodaLive/Sources/MyPage/Can/Status/GetCanChargeStatusResponseItem.swift new file mode 100644 index 0000000..411fea6 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/GetCanChargeStatusResponseItem.swift @@ -0,0 +1,14 @@ +// +// GetCanChargeStatusResponseItem.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetCanChargeStatusResponseItem: Decodable, Hashable { + let canTitle: String + let date: String + let chargeMethod: String +} diff --git a/SodaLive/Sources/MyPage/Can/Status/GetCanStatusResponse.swift b/SodaLive/Sources/MyPage/Can/Status/GetCanStatusResponse.swift new file mode 100644 index 0000000..ed37850 --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/GetCanStatusResponse.swift @@ -0,0 +1,13 @@ +// +// GetCanStatusResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetCanStatusResponse: Decodable { + let chargeCan: Int + let rewardCan: Int +} diff --git a/SodaLive/Sources/MyPage/Can/Status/GetCanUseStatusResponseItem.swift b/SodaLive/Sources/MyPage/Can/Status/GetCanUseStatusResponseItem.swift new file mode 100644 index 0000000..bb386ab --- /dev/null +++ b/SodaLive/Sources/MyPage/Can/Status/GetCanUseStatusResponseItem.swift @@ -0,0 +1,14 @@ +// +// GetCanUseStatusResponseItem.swift +// SodaLive +// +// Created by klaus on 2023/08/11. +// + +import Foundation + +struct GetCanUseStatusResponseItem: Decodable, Hashable { + let title: String + let date: String + let can: Int +} diff --git a/SodaLive/Sources/MyPage/CanCardView.swift b/SodaLive/Sources/MyPage/CanCardView.swift index 07235ba..9e1b0b6 100644 --- a/SodaLive/Sources/MyPage/CanCardView.swift +++ b/SodaLive/Sources/MyPage/CanCardView.swift @@ -13,7 +13,7 @@ struct CanCardView: View { var body: some View { HStack(spacing: 0) { - Button(action: {}) { + Button(action: { AppState.shared.setAppStep(step: .canStatus(refresh: refresh)) }) { HStack(spacing: 6.7) { Text("\(data.chargeCan + data.rewardCan)") .font(.custom(Font.bold.rawValue, size: 18.3)) @@ -29,7 +29,7 @@ struct CanCardView: View { Spacer() - Button(action: {}) { + Button(action: { AppState.shared.setAppStep(step: .canCharge(refresh: refresh)) }) { HStack(spacing: 7) { Image("ic_coin_w") .resizable()