캔 내역, 캔 충전 페이지 추가
This commit is contained in:
		| @@ -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) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import Foundation | ||||
| enum UserDefaultsKey: String, CaseIterable { | ||||
|     case auth | ||||
|     case role | ||||
|     case coin | ||||
|     case can | ||||
|     case token | ||||
|     case email | ||||
|     case userId | ||||
|   | ||||
							
								
								
									
										176
									
								
								SodaLive/Sources/IAP/StoreManager.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								SodaLive/Sources/IAP/StoreManager.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										109
									
								
								SodaLive/Sources/MyPage/Can/CanApi.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								SodaLive/Sources/MyPage/Can/CanApi.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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))"] | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										48
									
								
								SodaLive/Sources/MyPage/Can/CanRepository.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								SodaLive/Sources/MyPage/Can/CanRepository.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<CanApi>() | ||||
|      | ||||
|     func getCanStatus() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getCanStatus) | ||||
|     } | ||||
|      | ||||
|     func getCanChargeStatus() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getCanChargeStatus) | ||||
|     } | ||||
|      | ||||
|     func getCanUseStatus() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getCanUseStatus) | ||||
|     } | ||||
|      | ||||
|     func chargeCan(title: String, chargeCan: Int, price: Double, locale: String, paymentGateway: PaymentGateway = .APPLE_IAP) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.chargeCan(request: CanChargeRequest(title: title, chargeCan: chargeCan, paymentGateway: paymentGateway, price: price, locale: locale))) | ||||
|     } | ||||
|      | ||||
|     func verify(receiptString: String, chargeId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.verify(request: CanVerifyRequest(receiptString: receiptString, chargeId: chargeId))) | ||||
|     } | ||||
|      | ||||
|     func pgChargeCan(canId: Int) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.pgChargeCan(request: PgChargeRequest(canId: canId, paymentGateway: .PG))) | ||||
|     } | ||||
|      | ||||
|     func pgVerify(receiptId: String, orderId: String) -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.pgVerify(request: PgVerifyRequest(receiptId: receiptId, orderId: orderId))) | ||||
|     } | ||||
|      | ||||
|     func getCans() -> AnyPublisher<Response, MoyaError> { | ||||
|         return api.requestPublisher(.getCans) | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										40
									
								
								SodaLive/Sources/MyPage/Can/Charge/CanChargeRequest.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								SodaLive/Sources/MyPage/Can/Charge/CanChargeRequest.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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" | ||||
|     } | ||||
| } | ||||
							
								
								
									
										221
									
								
								SodaLive/Sources/MyPage/Can/Charge/CanChargeView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								SodaLive/Sources/MyPage/Can/Charge/CanChargeView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										58
									
								
								SodaLive/Sources/MyPage/Can/Charge/CanChargeViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								SodaLive/Sources/MyPage/Can/Charge/CanChargeViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										16
									
								
								SodaLive/Sources/MyPage/Can/GetCanResponse.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								SodaLive/Sources/MyPage/Can/GetCanResponse.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
							
								
								
									
										249
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPaymentView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										249
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPaymentView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										101
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPaymentViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										101
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPaymentViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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<CanChargeResponse>.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) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										310
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										310
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										123
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								SodaLive/Sources/MyPage/Can/Payment/CanPgPaymentViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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<CanChargeResponse>.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 | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								SodaLive/Sources/MyPage/Can/Payment/PaymentGateway.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								SodaLive/Sources/MyPage/Can/Payment/PaymentGateway.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| // | ||||
| //  PaymentGateway.swift | ||||
| //  SodaLive | ||||
| // | ||||
| //  Created by klaus on 2023/08/11. | ||||
| // | ||||
|  | ||||
| import Foundation | ||||
|  | ||||
| enum PaymentGateway: String, Codable { | ||||
|     case PG, APPLE_IAP | ||||
| } | ||||
							
								
								
									
										67
									
								
								SodaLive/Sources/MyPage/Can/Status/CanChargeStatusView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								SodaLive/Sources/MyPage/Can/Status/CanChargeStatusView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										199
									
								
								SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										199
									
								
								SodaLive/Sources/MyPage/Can/Status/CanStatusView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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 {} | ||||
|     } | ||||
| } | ||||
							
								
								
									
										147
									
								
								SodaLive/Sources/MyPage/Can/Status/CanStatusViewModel.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								SodaLive/Sources/MyPage/Can/Status/CanStatusViewModel.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AnyCancellable>() | ||||
|      | ||||
|     @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<GetCanStatusResponse>.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 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										72
									
								
								SodaLive/Sources/MyPage/Can/Status/CanUseStatusView.swift
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								SodaLive/Sources/MyPage/Can/Status/CanUseStatusView.swift
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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 | ||||
| } | ||||
| @@ -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() | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Yu Sung
					Yu Sung