diff --git a/MusicScale.xcodeproj/project.pbxproj b/MusicScale.xcodeproj/project.pbxproj index 954d1c8..f6e6d25 100755 --- a/MusicScale.xcodeproj/project.pbxproj +++ b/MusicScale.xcodeproj/project.pbxproj @@ -162,6 +162,7 @@ 44DE2C3E2AACBE9F000464E9 /* MIDIListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44DE2C3D2AACBE9F000464E9 /* MIDIListener.swift */; }; 44E0ACF1285F0BC6008BBB82 /* NSLayoutConstraint+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E0ACF0285F0BC6008BBB82 /* NSLayoutConstraint+.swift */; }; 44E0ACF3285F3FCD008BBB82 /* UIDevice+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E0ACF2285F3FCC008BBB82 /* UIDevice+.swift */; }; + 44E6FDB62B9817F300FF6972 /* IAPHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E6FDB52B9817F300FF6972 /* IAPHelper.swift */; }; 44E7A07528467692005C7909 /* SearchCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44E7A07428467692005C7909 /* SearchCategory.swift */; }; 44EE68152853A80B00A6D810 /* Quiz_IntroVC와 InProgressVC에서 무한루프.md in Resources */ = {isa = PBXBuildFile; fileRef = 44EE68142853A80B00A6D810 /* Quiz_IntroVC와 InProgressVC에서 무한루프.md */; }; 44EFA4B328541F0800565F93 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 44EFA4B228541F0800565F93 /* GoogleService-Info.plist */; }; @@ -354,6 +355,7 @@ 44DE2C3D2AACBE9F000464E9 /* MIDIListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MIDIListener.swift; sourceTree = ""; }; 44E0ACF0285F0BC6008BBB82 /* NSLayoutConstraint+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+.swift"; sourceTree = ""; }; 44E0ACF2285F3FCC008BBB82 /* UIDevice+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+.swift"; sourceTree = ""; }; + 44E6FDB52B9817F300FF6972 /* IAPHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IAPHelper.swift; sourceTree = ""; }; 44E7A07428467692005C7909 /* SearchCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCategory.swift; sourceTree = ""; }; 44EE68142853A80B00A6D810 /* Quiz_IntroVC와 InProgressVC에서 무한루프.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "Quiz_IntroVC와 InProgressVC에서 무한루프.md"; sourceTree = ""; }; 44EFA4B228541F0800565F93 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; @@ -702,6 +704,7 @@ 44709D54276A334F00BBF6C9 /* MusicScale */ = { isa = PBXGroup; children = ( + 44E6FDB42B9817E700FF6972 /* IAP */, 44872B0728608CD900C20E06 /* AdMob */, 4455D1E4285CB40100FB344E /* Audio */, 4426A0082854250800942655 /* Firebase */, @@ -842,6 +845,14 @@ path = Main; sourceTree = ""; }; + 44E6FDB42B9817E700FF6972 /* IAP */ = { + isa = PBXGroup; + children = ( + 44E6FDB52B9817F300FF6972 /* IAPHelper.swift */, + ); + path = IAP; + sourceTree = ""; + }; 44EE68132853A7DB00A6D810 /* Report */ = { isa = PBXGroup; children = ( @@ -1216,6 +1227,7 @@ 4417252D284B8FC60003CDC7 /* QuizQuestion.swift in Sources */, 449AF427282EC008008273CA /* ScaleInfoViewController.swift in Sources */, 445FA4C5284E60D70008D621 /* FlashcardsViewController.swift in Sources */, + 44E6FDB62B9817F300FF6972 /* IAPHelper.swift in Sources */, 445FA4CC284F547D0008D621 /* InitViewControllerFromStoryboard.swift in Sources */, 445FA4B9284BAE540008D621 /* Array+.swift in Sources */, 4455D1D2285A448200FB344E /* MIDISoundGenerator.swift in Sources */, diff --git a/MusicScale/AdMob/AdsManager.swift b/MusicScale/AdMob/AdsManager.swift index bb90e55..889054e 100755 --- a/MusicScale/AdMob/AdsManager.swift +++ b/MusicScale/AdMob/AdsManager.swift @@ -8,6 +8,55 @@ import Foundation import GoogleMobileAds +enum AdsError: Error { + case showAdNotAllowed +} + +class AdsManager: NSObject { + private override init() {} + static var shared = AdsManager() + + /// 배포 시 반드시 true로 + static var PRODUCT_MODE: Bool = true + + /// 광고 제거 구입했나요? + static var isPurchasedRemoveAd: Bool { + UserDefaults.standard.bool(forKey: InAppProducts.productIDs.first!) + } + + /// 최종 광고 표시 여부 + static var SHOW_AD: Bool { + return PRODUCT_MODE && !isPurchasedRemoveAd + } +} + +extension AdsManager: GADBannerViewDelegate { + private var showAd: Bool { + AdsManager.SHOW_AD + } + + func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + // print(#function, bannerView.rootViewController) + } + + func bannerViewWillPresentScreen(_ bannerView: GADBannerView) { + + } + + func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + // print(#function, error.localizedDescription) + + NotificationCenter.default.post(name: .networkIsOffline, object: nil) + + switch bannerView.rootViewController { + case is ScaleListTableViewController: + break + default: + break + } + } +} + /* 하단 광고 넣는 방법 == Delegate 사용하지 않는 경우 == @@ -59,7 +108,6 @@ import GoogleMobileAds - MatchKeysViewController (뒤로가기) */ - @discardableResult func setupBannerAds(_ viewController: UIViewController, container: UIView? = nil) -> GADBannerView? { @@ -95,10 +143,6 @@ func setupBannerAds(_ viewController: UIViewController, container: UIView? = nil return bannerView } -enum AdsError: Error { - case showAdNotAllowed -} - /** 전체 화면 광고: 사용 방법 1. 사용할 뷰컨트롤러의 멤버 변수로 `private var interstitial: GADInterstitialAd?` 추가 @@ -113,40 +157,3 @@ func setupFullAds(_ viewController: UIViewController) async throws -> GADInterst let request = GADRequest() return try await GADInterstitialAd.load(withAdUnitID: "ca-app-pub-6364767349592629/6979389977", request: request) } - -class AdsManager: NSObject, GADBannerViewDelegate { - - static var shared = AdsManager() - - /// 배포 시 반드시 true로 - static var PRODUCT_MODE: Bool = false - static var SHOW_AD: Bool { - // ... // - return PRODUCT_MODE && true - } - - private var showAd: Bool { - AdsManager.SHOW_AD - } - - func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { - // print(#function, bannerView.rootViewController) - } - - func bannerViewWillPresentScreen(_ bannerView: GADBannerView) { - - } - - func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { - // print(#function, error.localizedDescription) - - NotificationCenter.default.post(name: .networkIsOffline, object: nil) - - switch bannerView.rootViewController { - case is ScaleListTableViewController: - break - default: - break - } - } -} diff --git a/MusicScale/IAP/IAPHelper.swift b/MusicScale/IAP/IAPHelper.swift new file mode 100644 index 0000000..17609bf --- /dev/null +++ b/MusicScale/IAP/IAPHelper.swift @@ -0,0 +1,241 @@ +// +// IAPHelper.swift +// MusicScale +// +// Created by 윤범태 on 3/6/24. +// + +import StoreKit + +public struct InAppProducts { + private init() {} + + /// 앱 스토어 커넥트에 등록된 IAP의 제품 ID들의 리스트입니다. + public static let productIDs = [ + "com.bgsmm.MusicScale.IAP.1.removeAllAds" + ] + + private static let productIdentifiers: Set = Set(productIDs) + public static let helper = IAPHelper(productIds: InAppProducts.productIdentifiers) +} + +public typealias ProductIdentifier = String +public typealias ProductsRequestCompletionHandler = (_ success: Bool, _ products: [SKProduct]?) -> Void + +extension Notification.Name { + static let IAPHelperPurchaseNotification = Notification.Name("IAPHelperPurchaseNotification") + static let IAPHelperErrorNotification = Notification.Name("IAPHelperErrorNotification") +} + +open class IAPHelper: NSObject { + private let productIdentifiers: Set + private var purchasedProductIdentifiers: Set = [] + private var productsRequest: SKProductsRequest? + private var productsRequestCompletionHandler: ProductsRequestCompletionHandler? + + public init(productIds: Set) { + productIdentifiers = productIds + + for productIdentifier in productIds { + let purchased = UserDefaults.standard.bool(forKey: productIdentifier) + + if purchased { + purchasedProductIdentifiers.insert(productIdentifier) + print("IAP: (Maybe previously purchased): \(productIdentifier)") + } else { + print("IAP: (Maybe not purchased): \(productIdentifier)") + } + } + + super.init() + SKPaymentQueue.default().add(self) // App Store와 지불정보를 동기화하기 위한 Observer 추가 + + } +} + +extension IAPHelper { + /// 앱스토어에서 등록된 인앱결제 상품들을 가져옵니다. + public func inquireProductsRequest(_ completionHandler: @escaping ProductsRequestCompletionHandler) { + productsRequest?.cancel() + productsRequestCompletionHandler = completionHandler + productsRequest = SKProductsRequest(productIdentifiers: productIdentifiers) + productsRequest!.delegate = self + productsRequest!.start() + } + + /// 인앱결제 상품을 구입합니다. + public func buyProduct(_ product: SKProduct) { + print("IAP Buying: \(product.productIdentifier)...") + let payment = SKPayment(product: product) + SKPaymentQueue.default().add(payment) + } + + /// IAP 제품을 구매했는지 판별합니다. + /// - `purchasedProductIdentifiers`: IAPHelper 초기화시 또는 실제 제품을 구입했을 때 추가됩니다. + public func isProductPurchased(_ productIdentifier: ProductIdentifier) -> Bool { + return purchasedProductIdentifiers.contains(productIdentifier) + } + + /// 제품을 구입할 권한이 있는지 + /// - 계정을 관리하는 자가 별도로 있는 경우 제품 구매 권한이 없을 수도 있습니다. + public class func canMakePayments() -> Bool { + return SKPaymentQueue.canMakePayments() + } + + /// 구입내역을 복원합니다. + public func restorePurchases() { + SKPaymentQueue.default().restoreCompletedTransactions() + } +} + +extension IAPHelper: SKProductsRequestDelegate { + /* + SKProductsRequestDelegate에서 구현을 요구하는 메서드로 IAP 제품 리스트 목록을 성공적으로 받아왔을 때 실행됩니다. + */ + public func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { + print("IAP: Loaded list of products...") + let products = response.products + productsRequestCompletionHandler?(true, products) + clearRequestAndHandler() + + for p in products { + print("IAP - Found product: \(p.productIdentifier) \(p.localizedTitle) \(p.price.floatValue)") + } + } + + /* + SKProductsRequestDelegate에서 구현을 요구하는 메서드로 IAP 제품 리스트 목록을 가져오는데 실패했을 때 실행됩니다. + */ + public func request(_ request: SKRequest, didFailWithError error: Error) { + print("IAP - Failed to load list of products.") + print("IAP - Error: \(error.localizedDescription)") + productsRequestCompletionHandler?(false, nil) + clearRequestAndHandler() + } + + private func clearRequestAndHandler() { + productsRequest = nil + productsRequestCompletionHandler = nil + } +} + +extension IAPHelper: SKPaymentTransactionObserver { + /// paymentQueue(_:updatedTransactions:)는 프로토콜에서 실제로 필요한 유일한 방법입니다. + /// - 하나 이상의 트랜잭션 상태가 변경될 때 호출됩니다. + /// - 이 메서드는 업데이트된 트랜잭션 배열에서 각 트랜잭션의 상태를 평가하고 관련 도우미 메서드인 `complete(transaction:)`, `restore(transaction:)` 또는 `fail(transaction:)`을 호출합니다. + public func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { + for transaction in transactions { + switch transaction.transactionState { + case .purchased: + complete(transaction: transaction) + break + case .failed: + fail(transaction: transaction) + break + case .restored: + restore(transaction: transaction) + break + case .deferred: + print("IAP Transaction: Deferred") + break + case .purchasing: + print("IAP Transaction: Purchasing") + break + @unknown default: + break + } + } + } + + /// 구입 완료한 경우 트랜잭션 처리 + private func complete(transaction: SKPaymentTransaction) { + print("IAP Transaction Purchase: complete...") + deliverPurchaseNotificationFor(identifier: transaction.payment.productIdentifier) + SKPaymentQueue.default().finishTransaction(transaction) + } + + /// 복원 성공한 경우 트랜잭션 처리 + private func restore(transaction: SKPaymentTransaction) { + guard let productIdentifier = transaction.original?.payment.productIdentifier else { return } + + print("IAP Transaction: restore... \(productIdentifier)") + deliverPurchaseNotificationFor(identifier: productIdentifier) + SKPaymentQueue.default().finishTransaction(transaction) + } + + /// 구매 실패 + private func fail(transaction: SKPaymentTransaction) { + print("IAP Transaction Purchase: fail...") + + if let transactionError = transaction.error as NSError? { + print("IAP Transaction Error: \(transactionError.localizedDescription)") + } + + deliverPurchaseErrorNotification() + SKPaymentQueue.default().finishTransaction(transaction) + } + + /// 구매한 인앱 상품 키를 UserDefaults로 로컬에 저장 + /// - 실제로 구입 성공/복원된 경우에만 실행된다. + private func deliverPurchaseNotificationFor(identifier: String?) { + print(#function, identifier ?? "") + guard let identifier = identifier else { return } + + purchasedProductIdentifiers.insert(identifier) + UserDefaults.standard.set(true, forKey: identifier) + NotificationCenter.default.post(name: .IAPHelperPurchaseNotification, object: identifier) + } + + /// 제품 구매 실패시 Notification을 보냅니다. + /// - Notification의 이름은 `.IAPHelperErrorNotification`입니다. + private func deliverPurchaseErrorNotification() { + NotificationCenter.default.post(name: .IAPHelperErrorNotification, object: nil) + } +} + +extension IAPHelper { + /// 구매이력 영수증 가져오기 - 검증용 + public func getReceiptData() -> String? { + 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: []) + return receiptString + } + catch { + print("IAP - Couldn't read receipt data with error: " + error.localizedDescription) + return nil + } + } + + return nil + } +} + +extension SKProduct { + /// 각국 통화 포맷 처리기 + private static let formatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + return formatter + }() + + /// IAP 제품이 공짜인지 여부 + var isFree: Bool { + price == 0.00 + } + + /// 현지화된 가격 정보 + /// - 통화 기호가 같이 표시됩니다. + var localizedPrice: String? { + guard !isFree else { + return nil + } + + let formatter = SKProduct.formatter + formatter.locale = priceLocale + + return formatter.string(from: price) + } +} diff --git a/MusicScale/Main/AppDelegate.swift b/MusicScale/Main/AppDelegate.swift index 214a830..596413f 100755 --- a/MusicScale/Main/AppDelegate.swift +++ b/MusicScale/Main/AppDelegate.swift @@ -18,6 +18,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. + // IAP Test only - Must be removed when distributing + if let firstProduct = InAppProducts.productIDs.first { + UserDefaults.standard.set(false, forKey: firstProduct) + } + // in your AppDelegate's didFinishLaunching method so that the drop down will handle its display with the keyboard displayed even the first time a drop down is showed. DropDown.startListeningToKeyboard() diff --git a/MusicScale/Main/Base.lproj/Main.storyboard b/MusicScale/Main/Base.lproj/Main.storyboard index 29466f5..9b76b96 100755 --- a/MusicScale/Main/Base.lproj/Main.storyboard +++ b/MusicScale/Main/Base.lproj/Main.storyboard @@ -1383,10 +1383,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -1407,7 +1454,7 @@ - + @@ -1428,7 +1475,7 @@ - + @@ -1446,7 +1493,7 @@ - + @@ -1464,7 +1511,7 @@ - + @@ -1486,32 +1533,10 @@ - - - - - - - - - - - - - - - - - + diff --git a/MusicScale/Main/ja.lproj/Main.strings b/MusicScale/Main/ja.lproj/Main.strings index db55d18..dd52ec8 100755 --- a/MusicScale/Main/ja.lproj/Main.strings +++ b/MusicScale/Main/ja.lproj/Main.strings @@ -313,7 +313,7 @@ "X1c-ti-nUZ.text" = "題名"; /* Class = "UITableViewSection"; headerTitle = "Available Soon"; ObjectID = "X4k-Io-zGQ"; */ -"X4k-Io-zGQ.headerTitle" = "すぐに利用可能"; +"X4k-Io-zGQ.headerTitle" = "アプリ内商品の購入"; /* Class = "UITextView"; text = "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."; ObjectID = "XEK-LT-H7l"; */ "XEK-LT-H7l.text" = "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."; @@ -482,7 +482,7 @@ "qy4-OM-nif.normalTitle" = "答え"; /* Class = "UILabel"; text = "🙇‍♂️"; ObjectID = "r9T-fX-9kM"; */ -"r9T-fX-9kM.text" = "🙇‍♂️"; +"r9T-fX-9kM.text" = "購入リストの復元"; /* Class = "UITableViewSection"; headerTitle = "Metadata"; ObjectID = "rAN-Wf-0d2"; */ "rAN-Wf-0d2.headerTitle" = "メタデータ"; diff --git a/MusicScale/Main/ko.lproj/Main.strings b/MusicScale/Main/ko.lproj/Main.strings index a127dc1..39c4ef7 100755 --- a/MusicScale/Main/ko.lproj/Main.strings +++ b/MusicScale/Main/ko.lproj/Main.strings @@ -313,7 +313,7 @@ "X1c-ti-nUZ.text" = "제목"; /* Class = "UITableViewSection"; headerTitle = "Available Soon"; ObjectID = "X4k-Io-zGQ"; */ -"X4k-Io-zGQ.headerTitle" = "곧 이용할 수 있습니다"; +"X4k-Io-zGQ.headerTitle" = "앱 내 결제"; /* Class = "UITextView"; text = "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."; ObjectID = "XEK-LT-H7l"; */ "XEK-LT-H7l.text" = "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."; @@ -482,7 +482,7 @@ "qy4-OM-nif.normalTitle" = "정답"; /* Class = "UILabel"; text = "🙇‍♂️"; ObjectID = "r9T-fX-9kM"; */ -"r9T-fX-9kM.text" = "🙇‍♂️"; +"r9T-fX-9kM.text" = "구입 목록 복원"; /* Class = "UITableViewSection"; headerTitle = "Metadata"; ObjectID = "rAN-Wf-0d2"; */ "rAN-Wf-0d2.headerTitle" = "메타데이터"; diff --git a/MusicScale/ViewController/Archive/ArchiveMainTableViewController.swift b/MusicScale/ViewController/Archive/ArchiveMainTableViewController.swift index 3281d48..6df5944 100755 --- a/MusicScale/ViewController/Archive/ArchiveMainTableViewController.swift +++ b/MusicScale/ViewController/Archive/ArchiveMainTableViewController.swift @@ -22,6 +22,7 @@ class ArchiveMainTableViewController: UITableViewController { } override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) if viewModel == nil { viewModel = PostListViewModel() @@ -44,8 +45,15 @@ class ArchiveMainTableViewController: UITableViewController { self.tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: false) } } + + NotificationCenter.default.removeObserver(self, name: .IAPHelperPurchaseNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleIAPPurchase(_:)), name: .IAPHelperPurchaseNotification, object: nil) } + @objc func handleIAPPurchase(_ notification: Notification) { + tableView.reloadData() + } + override func viewDidLoad() { super.viewDidLoad() diff --git a/MusicScale/ViewController/Quiz/FlashcardsViewController.swift b/MusicScale/ViewController/Quiz/FlashcardsViewController.swift index 3d1c6e8..45a5aae 100755 --- a/MusicScale/ViewController/Quiz/FlashcardsViewController.swift +++ b/MusicScale/ViewController/Quiz/FlashcardsViewController.swift @@ -228,7 +228,7 @@ class FlashcardsViewController: InQuizViewController { setQuizStatFromCurrentQuestion(true, elapsedSeconds: resetTimer()) - view.makeToast("[확인] 성공했습니다! 이 기세를 이어갑시다!", duration: 2, position: .top) + view.makeToast("Success! Let’s keep this momentum going!".localized(), duration: 1.6, position: .top) } private func sendToRemind() { @@ -238,7 +238,7 @@ class FlashcardsViewController: InQuizViewController { setQuizStatFromCurrentQuestion(true, elapsedSeconds: resetTimer()) - view.makeToast("[다시 학습하기] 알겠습니다! 조금 더 분발합시다!", duration: 2, position: .top) + view.makeToast("Again! Let's try harder!".localized(), duration: 1.6, position: .top) } } diff --git a/MusicScale/ViewController/Quiz/QuizInProgressViewController.swift b/MusicScale/ViewController/Quiz/QuizInProgressViewController.swift index 1b8402d..853e684 100755 --- a/MusicScale/ViewController/Quiz/QuizInProgressViewController.swift +++ b/MusicScale/ViewController/Quiz/QuizInProgressViewController.swift @@ -40,6 +40,14 @@ class QuizInProgressViewController: UIViewController { circlularSlider.endPointValue = forecastPercent let displayValue = Int(round(forecastPercent * 100)) lblPercent.text = "\(displayValue)%" + + NotificationCenter.default.removeObserver(self, name: .IAPHelperPurchaseNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleIAPPurchase(_:)), name: .IAPHelperPurchaseNotification, object: nil) + } + + @objc func handleIAPPurchase(_ notification: Notification) { + viewBannerContainer.removeFromSuperview() + interstitial = nil } override func viewWillDisappear(_ animated: Bool) { diff --git a/MusicScale/ViewController/Quiz/QuizIntroTableViewController.swift b/MusicScale/ViewController/Quiz/QuizIntroTableViewController.swift index b239b30..0bac4ca 100755 --- a/MusicScale/ViewController/Quiz/QuizIntroTableViewController.swift +++ b/MusicScale/ViewController/Quiz/QuizIntroTableViewController.swift @@ -41,7 +41,7 @@ class QuizIntroTableViewController: UITableViewController { } override func viewWillAppear(_ animated: Bool) { - // print(try? QuizStatsCDService.shared.readEntityList()) + super.viewWillAppear(animated) // 기존 저장 LeitnerSystem 오브젝트가 있는 경우 리다리렉트 if quizStore.savedLeitnerSystem != nil { @@ -77,6 +77,13 @@ class QuizIntroTableViewController: UITableViewController { // enharmonic Mode lblEnharmonicModeDetail.text = quizStore.enharmonicMode.titleValue + + NotificationCenter.default.removeObserver(self, name: .IAPHelperPurchaseNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleIAPPurchase(_:)), name: .IAPHelperPurchaseNotification, object: nil) + } + + @objc func handleIAPPurchase(_ notification: Notification) { + tableView.reloadData() } override func viewDidLoad() { diff --git a/MusicScale/ViewController/ScaleInfo/ScaleListTableViewController.swift b/MusicScale/ViewController/ScaleInfo/ScaleListTableViewController.swift index b00c531..41de8df 100755 --- a/MusicScale/ViewController/ScaleInfo/ScaleListTableViewController.swift +++ b/MusicScale/ViewController/ScaleInfo/ScaleListTableViewController.swift @@ -115,6 +115,17 @@ class ScaleListTableViewController: UITableViewController { // NotificationCenter.default.removeObserver(self, name: .networkIsOffline, object: nil) // NotificationCenter.default.addObserver(self, selector: #selector(a), name: .networkIsOffline, object: nil) + + NotificationCenter.default.removeObserver(self, name: .IAPHelperPurchaseNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleIAPPurchase(_:)), name: .IAPHelperPurchaseNotification, object: nil) + + // @objc func handleIAPPurchase(_ notification: Notification) { + // tableView.reloadData() + // } + } + + @objc func handleIAPPurchase(_ notification: Notification) { + tableView.reloadData() } override func viewWillDisappear(_ animated: Bool) { diff --git a/MusicScale/ViewController/Settings/SettingTableViewController.swift b/MusicScale/ViewController/Settings/SettingTableViewController.swift index 20e22c2..0dbfb1b 100755 --- a/MusicScale/ViewController/Settings/SettingTableViewController.swift +++ b/MusicScale/ViewController/Settings/SettingTableViewController.swift @@ -7,6 +7,7 @@ import UIKit import MessageUI +import StoreKit import CodableCSV class SettingTableViewController: UITableViewController { @@ -15,6 +16,8 @@ class SettingTableViewController: UITableViewController { @IBOutlet weak var lblCurrentAppearance: UILabel! @IBOutlet weak var lblIsShowHWKeyMapping: UILabel! + private var iapProducts: [SKProduct]? + // ========== 구조 변경할 경우 반드시 업데이트 ========== private let playbackInstCell = IndexPath(row: 0, section: 0) private let pianoInstCell = IndexPath(row: 1, section: 0) @@ -24,8 +27,12 @@ class SettingTableViewController: UITableViewController { private let setEnhamonicCell = IndexPath(row: 2, section: 1) private let exportToCsvCell = IndexPath(row: 3, section: 1) - private let githubLinkCell = IndexPath(row: 3, section: 2) - private let sendMailCell = IndexPath(row: 2, section: 2) + private let SECTION_IAP = 2 + private let restorePurchasesCellIndexPath = IndexPath(row: 0, section: 2) + private let firstIAPProductCellIndexPath = IndexPath(row: 1, section: 2) + + private let githubLinkCell = IndexPath(row: 3, section: 3) + private let sendMailCell = IndexPath(row: 2, section: 3) private let SECTION_BANNER = 4 // ============================================== @@ -49,9 +56,32 @@ class SettingTableViewController: UITableViewController { DispatchQueue.main.async { setupBannerAds(self, container: self.viewBannerContainer) } + + initIAP() } + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + switch segue.identifier { + case "InstrumentSegue": + let selectVC = segue.destination as! InstrumentTableViewController + let place = sender as! InstrumentTableViewController.Place + selectVC.place = place + case "HelpSegue", "LicenseSegue": + let webVC = segue.destination as! PDFViewController + webVC.category = segue.identifier == "HelpSegue" ? .help : .licenses + default: + break + } + } +} + +extension SettingTableViewController { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if let iapProducts, indexPath.section == SECTION_IAP, indexPath.row != 0 { + let product = iapProducts[indexPath.row - 1] + purchaseIAP(productID: product.productIdentifier) + } + switch indexPath { case playbackInstCell: performSegue(withIdentifier: "InstrumentSegue", sender: InstrumentTableViewController.Place.playback) @@ -69,6 +99,8 @@ class SettingTableViewController: UITableViewController { showAppearanceActionSheet() case setHWKeyMappingCellIndexPath: showHWKeyMappingActionSheet() + case restorePurchasesCellIndexPath: + restoreIAP() default: break } @@ -82,6 +114,10 @@ class SettingTableViewController: UITableViewController { simpleAlert(self, message: "Export the currently saved scale informations to a CSV file. CSV files can be opened with spreadsheet apps such as Microsoft Excel, Google Spreadsheet or Apple Numbers.".localized(), title: "Export to CSV file".localized(), handler: nil) case setHWKeyMappingCellIndexPath: simpleAlert(self, message: "When you connect an USB/Bluetooth keyboard to an iOS/iPadOS device, or run the app through an Apple Silicon series Mac, you can play the piano keys using the hardware keyboard. In this case, you can decide whether or not to display the corresponding hardware keys above the piano keys displayed in the app.".localized(), title: "Display Hardware Key on Piano".localized(), handler: nil) + case restorePurchasesCellIndexPath: + simpleAlert(self, message: "If you have already purchased a product but the product is not applied due to reinstallation of the app, you can use Restore Purchase History. This only works if you have previously purchased the item.".localized(), title: "Restore Purchase History".localized(), handler: nil) + case firstIAPProductCellIndexPath: + simpleAlert(self, message: "Purchasing this product will permanently remove all banner and full screen ads from the app. Use the app comfortably without ads!".localized(), title: "In-app product I. Introduction".localized(), handler: nil) default: break } @@ -106,24 +142,34 @@ class SettingTableViewController: UITableViewController { override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { if section == SECTION_BANNER && !AdsManager.SHOW_AD { return 0 + } else if section == SECTION_IAP { + return 1 + InAppProducts.productIDs.count } return super.tableView(tableView, numberOfRowsInSection: section) - } - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - switch segue.identifier { - case "InstrumentSegue": - let selectVC = segue.destination as! InstrumentTableViewController - let place = sender as! InstrumentTableViewController.Place - selectVC.place = place - case "HelpSegue", "LicenseSegue": - let webVC = segue.destination as! PDFViewController - webVC.category = segue.identifier == "HelpSegue" ? .help : .licenses - default: - break + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = super.tableView(tableView, cellForRowAt: indexPath) + + if let iapProducts, + indexPath.section == SECTION_IAP, + indexPath.row != 0, + cell.contentView.subviews.count >= 2, + let firstLabel = cell.contentView.subviews.first as? UILabel, + let secondLabel = cell.contentView.subviews[safe: 1] as? UILabel { + + let product = iapProducts[indexPath.row - 1] + firstLabel.text = product.localizedTitle + " (\(product.localizedPrice ?? ""))" + + let isPurchased = InAppProducts.helper.isProductPurchased(product.productIdentifier) + secondLabel.text = isPurchased ? "Purchased".localized() : "Not Purchased".localized() + secondLabel.textColor = isPurchased ? .systemGreen : nil + + return cell } + + return cell } } @@ -240,3 +286,85 @@ extension SettingTableViewController: MFMailComposeViewControllerDelegate { } } + +/* + ===> 인앱 결제로 광고 제거 + */ +extension SettingTableViewController { + private func initIAP() { + NotificationCenter.default.addObserver(self, selector: #selector(handleIAPPurchase(_:)), name: .IAPHelperPurchaseNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(hadnleIAPError(_:)), name: .IAPHelperErrorNotification, object: nil) + + // IAP 불러오기 + InAppProducts.helper.inquireProductsRequest { [weak self] (success, products) in + guard let self, success else { return } + self.iapProducts = products + + DispatchQueue.main.async { [weak self] in + guard let self, + let products else { + return + } + + // 불러오기 후 할 UI 작업 + tableView.reloadSections([SECTION_IAP], with: .none) + + products.forEach { + if !InAppProducts.helper.isProductPurchased($0.productIdentifier) { + print("\($0.localizedTitle) (\($0.price))") + } + } + } + } + + if InAppProducts.helper.isProductPurchased(InAppProducts.productIDs[0]) || UserDefaults.standard.bool(forKey: InAppProducts.productIDs[0]) { + // 이미 구입한 경우 UI 업데이트 작업 + } + } + + /// 구매: 인앱 결제 버튼 눌렀을 때 + private func purchaseIAP(productID: String) { + if let product = iapProducts?.first(where: {productID == $0.productIdentifier}), + !InAppProducts.helper.isProductPurchased(productID) { + InAppProducts.helper.buyProduct(product) + SwiftSpinner.show("Processing in-app purchase operation.\nPlease wait...".localized()) + } else { + simpleAlert(self, message: "Your purchase has been completed. You will no longer see ads in the app. If ads are not removed from some screens, force quit the app and relaunch it.".localized(), title: "Purchase completed".localized(), handler: nil) + } + } + + /// 복원: 인앱 복원 버튼 눌렀을 때 + private func restoreIAP() { + InAppProducts.helper.restorePurchases() + } + + /// 결제 후 Notification을 받아 처리 + @objc func handleIAPPurchase(_ notification: Notification) { + guard notification.object is String else { + simpleAlert(self, message: "Purchase failed: Please try again.".localized()) + return + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + simpleAlert(self, message: "Your purchase has been completed. You will no longer see ads in the app. If ads are not removed from some screens, force quit the app and relaunch it.".localized(), title: "Purchase completed".localized()) { [weak self] action in + guard let self else { return } + // 결제 성공하면 해야할 작업... + // 1. 로딩 인디케이터 숨기기 + SwiftSpinner.hide() + + // 2. 세팅VC 광고 제거 (나머지 뷰는 다시 들어가면 제거되어 있음) + tableView.reloadSections([SECTION_BANNER], with: .none) + + // 3. 버튼 UI 업데이트 + tableView.reloadData() + } + } + } + + // 에러 발생시(결제 취소 포함) 작업 + @objc func hadnleIAPError(_ notification: Notification) { + print(#function) + SwiftSpinner.hide() + } +} diff --git a/MusicScale/ja.lproj/Localizable.strings b/MusicScale/ja.lproj/Localizable.strings index c33c151..a8289dd 100755 --- a/MusicScale/ja.lproj/Localizable.strings +++ b/MusicScale/ja.lproj/Localizable.strings @@ -128,3 +128,19 @@ "On" = "オン"; "Off" = "オフ"; "Select whether hardware keyboard mappings are displayed or not on the piano" = "ハードウェアのキーボードマッピングをピアノの鍵盤の上に表示するかどうかを選択します。"; + +"If you have already purchased a product but the product is not applied due to reinstallation of the app, you can use Restore Purchase History. This only works if you have previously purchased the item." = "すでに商品を購入しているがアプリの再インストールなどにより製品が適用されていない場合は、購入リストの復元を利用できます。 以前に商品を購入した場合にのみ機能します。"; +"Restore Purchase History" = "購入リストの復元"; +"Purchasing this product will permanently remove all banner and full screen ads from the app. Use the app comfortably without ads!" = "この商品を購入すると、アプリに表示されるすべてのバナー広告と全画面広告を完全に削除できます。 広告なしで快適にアプリを使用してください!"; +"In-app product I. Introduction" = "アプリ内商品 I. について"; + +"Processing in-app purchase operation.\nPlease wait..." = "お支払い作業を処理中です。\nしばらくお待ちください..."; +"Your purchase has been completed. You will no longer see ads in the app. If ads are not removed from some screens, force quit the app and relaunch it." = "購入完了です。 これでアプリに広告が表示されなくなります。一部の画面で広告が削除されていない場合は、アプリを強制終了してからもう一度実行してください。"; +"Purchase completed" = "購入完了"; +"Purchase failed: Please try again." = "購入に失敗しました:もう一度お試しください。"; + +"Purchased" = "購入"; +"Not Purchased" = "未購入"; + +"Success! Let’s keep this momentum going!" = "【確認】 成功しました!この勢いを続けていきましょう!"; +"Again! Let's try harder!" = "【もう一度】 わかりました!もう少し気をつけましょう!"; diff --git a/MusicScale/ko.lproj/Localizable.strings b/MusicScale/ko.lproj/Localizable.strings index dceeaa2..fc12c78 100755 --- a/MusicScale/ko.lproj/Localizable.strings +++ b/MusicScale/ko.lproj/Localizable.strings @@ -130,3 +130,19 @@ "On" = "켜기"; "Off" = "끄기"; "Select whether hardware keyboard mappings are displayed or not on the piano" = "하드웨어 키보드 매핑을 피아노 건반 위에 표시할지 여부를 선택하세요."; + +"If you have already purchased a product but the product is not applied due to reinstallation of the app, you can use Restore Purchase History. This only works if you have previously purchased the item." = "이미 상품을 구매하였으나 앱의 재설치 등으로 인해 제품이 적용되지 않은 경우, 구입 목록 복원을 이용할 수 있습니다. 이전에 상품을 구매한 경우에만 작동합니다."; +"Restore Purchase History" = "구입 목록 복원"; +"Purchasing this product will permanently remove all banner and full screen ads from the app. Use the app comfortably without ads!" = "이 상품을 구입하면 앱에서 표시되는 모든 배너 광고 및 전체 화면 광고를 영구적으로 제거할 수 있습니다. 광고 없이 쾌적하게 앱을 이용하세요!"; +"In-app product I. Introduction" = "앱 내 상품 I. 소개"; + +"Processing in-app purchase operation.\nPlease wait..." = "결제 작업을 처리중입니다.\n잠시만 기다려 주세요..."; +"Your purchase has been completed. You will no longer see ads in the app. If ads are not removed from some screens, force quit the app and relaunch it." = "구매 완료되었습니다. 이제 앱에서 광고가 표시되지 않습니다. 일부 화면에서 광고가 제거되지 않은 경우 앱을 강제 종료 후 다시 실행해주세요."; +"Purchase completed" = "구매 완료"; +"Purchase failed: Please try again." = "구매 실패: 다시 시도해주세요."; + +"Purchased" = "구입 완료"; +"Not Purchased" = "미구입"; + +"Success! Let’s keep this momentum going!" = "[확인] 성공했습니다! 이 기세를 이어갑시다!"; +"Again! Let's try harder!" = "[다시 학습하기] 알겠습니다! 조금 더 분발합시다!";