SwiftUI app freezing when using multiple product identifiers in StoreKit - swift

I'm currently learning Swift and following some tutorials but I'm stuck on a StoreKit issue.
The code works when I provide a single productIdentifier, but when I provide more than 1 in the Set, the entire app hangs on loading. This is in the iOS Simulator, and on a device. I've got 2 identifiers in the set, and both of these work individually, but not at the same time. My code looks the same as the original tutorial (video) so I don't know where I'm going long.
Entire Store.swift file below. Problem appears to be in the fetchProducts function, but I'm not sure. Can anyone point me in the right direction?
import StoreKit
typealias FetchCompletionHandler = (([SKProduct]) -> Void)
typealias PurchaseCompletionHandler = ((SKPaymentTransaction?) -> Void)
class Store: NSObject, ObservableObject {
#Published var allRecipes = [Recipe]() {
didSet {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
for index in self.allRecipes.indices {
self.allRecipes[index].isLocked = !self.completedPurchases.contains(self.allRecipes[index].id)
}
}
}
}
private let allProductIdentifiers = Set(["com.myname.ReceipeStore.test", "com.myname.ReceipeStore.test2"])
private var completedPurchases = [String]()
private var productsRequest: SKProductsRequest?
private var fetchedProducts = [SKProduct]()
private var fetchCompletionHandler: FetchCompletionHandler?
private var purchaseCompletionHandler: PurchaseCompletionHandler?
override init() {
super.init()
startObservingPaymentQueue()
fetchProducts { products in
self.allRecipes = products.map { Recipe(product: $0) }
}
}
private func startObservingPaymentQueue() {
SKPaymentQueue.default().add(self)
}
private func fetchProducts(_ completion: #escaping FetchCompletionHandler) {
guard self.productsRequest == nil else { return }
fetchCompletionHandler = completion
productsRequest = SKProductsRequest(productIdentifiers: allProductIdentifiers)
productsRequest!.delegate = self
productsRequest!.start()
}
private func buy(_ product: SKProduct, competion: #escaping PurchaseCompletionHandler) {
purchaseCompletionHandler = competion
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
}
extension Store {
func product(for identififier: String) -> SKProduct? {
return fetchedProducts.first(where: { $0.productIdentifier == identififier })
}
func purchaseProduct(_ product: SKProduct) {
buy(product) { _ in }
}
}
extension Store: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
var shouldFinishTransactions = false
switch transaction.transactionState {
case .purchased, .restored:
completedPurchases.append(transaction.payment.productIdentifier)
shouldFinishTransactions = true
case .failed:
shouldFinishTransactions = true
case .deferred, .purchasing:
break
#unknown default:
break
}
if shouldFinishTransactions {
SKPaymentQueue.default().finishTransaction(transaction)
DispatchQueue.main.async {
self.purchaseCompletionHandler?(transaction)
self.purchaseCompletionHandler = nil
}
}
}
}
}
// loading products from the store
extension Store: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
let loadedProducts = response.products
let invalidProducts = response.invalidProductIdentifiers
guard !loadedProducts.isEmpty else {
print("Could not load the products!")
if !invalidProducts.isEmpty {
print("Invalid products found: \(invalidProducts)")
}
productsRequest = nil
return
}
// cache the feteched products
fetchedProducts = loadedProducts
// notify anyone waiting on the product load (swift UI view)
DispatchQueue.main.async {
self.fetchCompletionHandler?(loadedProducts)
self.fetchCompletionHandler = nil
self.productsRequest = nil
}
}
}```

It looks like you're running all of your requests on the main DispatchQueue, this will block other main queue work until completed. You should consider handling some of these tasks with a custom concurrent queue. This bit of sample code should get the ball rolling.
func requestProducts(_ productIdentifiers: Set<ProductIdentifier>, handler: #escaping ProductRequestHandler) {
// Set request handler
productRequest?.cancel()
productRequestHandler = handler
// Request
productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest?.delegate = self
productRequest?.start()
}
func requestPrices() {
// Retry interval, 5 seconds, set this to your liking
let retryTimeOut = 5.0
var local1: String? = nil
var local2: String? = nil
let bundleIdentifier = Bundle.main.bundleIdentifier!
let queue = DispatchQueue(label: bundleIdentifier + ".IAPQueue", attributes: .concurrent)
// Request price
queue.async {
var trying = true
while(trying) {
let semaphore = DispatchSemaphore(value: 0)
requestProducts(Set(arrayLiteral: SettingsViewController.pID_1000Credits, SettingsViewController.pID_2000Credits)) { (response, error) in
local1 = response?.products[0].localizedPrice
local2 = response?.products[1].localizedPrice
semaphore.signal()
}
// We will keep checking on this thread until completed
_ = semaphore.wait(timeout: .now() + retryTimeOut)
if(local2 != nil) { trying = false }
}
// Update with main thread once request is completed
DispatchQueue.main.async {
self.price1 = local1 ?? "$0.99"
self.price2 = local2 ?? "$1.99"
}
}
}
extension SKProduct {
// Helper function, not needed for this example
public var localizedPrice: String? {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = self.priceLocale
return formatter.string(from: self.price)
}

Related

Skproduct skipping product because no price was available

This is my first experience with creating purchases. The app I'm working on hasn't been released yet. I've been testing subscriptions locally using the Configuration.storekit file. Everything worked fine.
I recently encountered a problem - my subscriptions are no longer displayed in the project. I got an error like this in the terminal:
UPD:
I decided to check the application on the emulator and everything works there. As far as I remember everything broke after installing xcode 14 and updating to ios 16.
On the physical device, the problem remains.
I didn't change the code in those places. I tried to create new .storekit files, but it still doesn't work.
I tried to load the .storekit file with the synchronization. In it the price is pulled up and displayed correctly, as on the site, but in the terminal again writes the same error.
Here is the file that works with purchases:
import StoreKit
typealias RequestProductsResult = Result<[SKProduct], Error>
typealias PurchaseProductResult = Result<Bool, Error>
typealias RequestProductsCompletion = (RequestProductsResult) -> Void
typealias PurchaseProductCompletion = (PurchaseProductResult) -> Void
class Purchases: NSObject {
static let `default` = Purchases()
private let productIdentifiers = Set<String>(
arrayLiteral: "test.1month", "test.6month", "test.12month"
)
private var products: [String: SKProduct]?
private var productRequest: SKProductsRequest?
private var productsRequestCallbacks = [RequestProductsCompletion]()
fileprivate var productPurchaseCallback: ((PurchaseProductResult) -> Void)?
func initialize(completion: #escaping RequestProductsCompletion) {
requestProducts(completion: completion)
}
private func requestProducts(completion: #escaping RequestProductsCompletion) {
guard productsRequestCallbacks.isEmpty else {
productsRequestCallbacks.append(completion)
return
}
productsRequestCallbacks.append(completion)
let productRequest = SKProductsRequest(productIdentifiers: productIdentifiers)
productRequest.delegate = self
productRequest.start()
self.productRequest = productRequest
}
func purchaseProduct(productId: String, completion: #escaping (PurchaseProductResult) -> Void) {
guard productPurchaseCallback == nil else {
completion(.failure(PurchasesError.purchaseInProgress))
return
}
guard let product = products?[productId] else {
completion(.failure(PurchasesError.productNotFound))
return
}
productPurchaseCallback = completion
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
public func restorePurchases(completion: #escaping (PurchaseProductResult) -> Void) {
guard productPurchaseCallback == nil else {
completion(.failure(PurchasesError.purchaseInProgress))
return
}
productPurchaseCallback = completion
SKPaymentQueue.default().restoreCompletedTransactions()
}
}
extension Purchases: SKProductsRequestDelegate {
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
guard !response.products.isEmpty else {
print("Found 0 products")
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
return
}
var products = [String: SKProduct]()
for skProduct in response.products {
print("Found product: \(skProduct.productIdentifier)")
products[skProduct.productIdentifier] = skProduct
}
self.products = products
productsRequestCallbacks.forEach { $0(.success(response.products)) }
productsRequestCallbacks.removeAll()
}
func request(_ request: SKRequest, didFailWithError error: Error) {
print("Failed to load products with error:\n \(error)")
productsRequestCallbacks.forEach { $0(.failure(error)) }
productsRequestCallbacks.removeAll()
}
}
extension Purchases: SKPaymentTransactionObserver {
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased, .restored:
if finishTransaction(transaction) {
SKPaymentQueue.default().finishTransaction(transaction)
productPurchaseCallback?(.success(true))
UserDefaults.setValue(true, forKey: "isPurchasedSubscription")
} else {
productPurchaseCallback?(.failure(PurchasesError.unknown))
}
case .failed:
productPurchaseCallback?(.failure(transaction.error ?? PurchasesError.unknown))
SKPaymentQueue.default().finishTransaction(transaction)
default:
break
}
}
productPurchaseCallback = nil
}
}
extension Purchases {
func finishTransaction(_ transaction: SKPaymentTransaction) -> Bool {
let productId = transaction.payment.productIdentifier
print("Product \(productId) successfully purchased")
return true
}
}
There is also a file that is responsible for displaying available subscription options:
//
// PremiumRatesTVC.swift
// CalcYou
//
// Created by Admin on 29.08.2022.
//
import StoreKit
import UIKit
class PremiumRatesTVC: UITableViewController {
var oneMonthPrice = ""
var sixMonthPrice = ""
var twelveMonthPrice = ""
#IBOutlet weak var oneMonthPriceLabel: UILabel!
#IBOutlet weak var oneMothDailyPriceLabel: UILabel!
#IBOutlet weak var sixMonthPriceLabel: UILabel!
#IBOutlet weak var sixMonthDailyPriceLabel: UILabel!
#IBOutlet weak var twelveMonthPriceLabel: UILabel!
#IBOutlet weak var twelveMonthDailyPriceLabel: UILabel!
#IBOutlet weak var tableViewCellOneMonth: UITableViewCell!
#IBOutlet weak var tableViewCellSixMonth: UITableViewCell!
#IBOutlet weak var tableViewCellTwelveMonth: UITableViewCell!
#IBAction func cancelButton(_ sender: Any) {
dismiss(animated: true, completion: nil)
}
// MARK: ViewDidLoad()
override func viewDidLoad() {
super.viewDidLoad()
hideSubscriptions()
navigationItem.title = "Premium PRO version"
Purchases.default.initialize { [weak self] result in
guard let self = self else { return }
switch result {
case let .success(products):
guard products.count > 0 else {
let message = "Failed to get a list of subscriptions. Please try again later."
self.showMessage("Oops", withMessage: message)
return
}
self.showSubscriptions()
DispatchQueue.main.async {
self.updateInterface(products: products)
}
default:
break
}
}
}
// MARK: Functions()
private func updateInterface(products: [SKProduct]) {
updateOneMonth(with: products[0])
updateSixMonth(with: products[1])
updateTwelveMonth(with: products[2])
}
private func hideSubscriptions() {
DispatchQueue.main.async {
self.tableViewCellOneMonth.isHidden = true
self.tableViewCellSixMonth.isHidden = true
self.tableViewCellTwelveMonth.isHidden = true
}
}
private func showSubscriptions() {
DispatchQueue.main.async {
self.tableViewCellOneMonth.isHidden = false
self.tableViewCellSixMonth.isHidden = false
self.tableViewCellTwelveMonth.isHidden = false
}
}
func showMessage(_ title: String, withMessage message: String) {
DispatchQueue.main.async {
let alert = UIAlertController(title: title,
message: message,
preferredStyle: UIAlertController.Style.alert)
let dismiss = UIAlertAction(title: "Ok",
style: UIAlertAction.Style.default,
handler: nil)
alert.addAction(dismiss)
self.present(alert, animated: true, completion: nil)
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let storyboard = UIStoryboard(name: "Main", bundle: nil)
if indexPath.section == 0 && indexPath.row == 0 {
guard let premiumBuyVC = storyboard.instantiateViewController(identifier: "PremiumBuyVC") as? PremiumBuyVC else { return }
premiumBuyVC.price = oneMonthPrice
premiumBuyVC.productId = "1month"
premiumBuyVC.period = "per month"
show(premiumBuyVC, sender: nil)
}
if indexPath.section == 1 && indexPath.row == 0 {
guard let premiumBuyVC = storyboard.instantiateViewController(identifier: "PremiumBuyVC") as? PremiumBuyVC else { return }
premiumBuyVC.price = sixMonthPrice
premiumBuyVC.productId = "6month"
premiumBuyVC.period = "per 6 month"
show(premiumBuyVC, sender: nil)
}
if indexPath.section == 2 && indexPath.row == 0 {
guard let premiumBuyVC = storyboard.instantiateViewController(identifier: "PremiumBuyVC") as? PremiumBuyVC else { return }
premiumBuyVC.price = twelveMonthPrice
premiumBuyVC.productId = "12month"
premiumBuyVC.period = "per 12 month"
show(premiumBuyVC, sender: nil)
}
}
}
extension SKProduct {
public var localizedPrice: String? {
let numberFormatter = NumberFormatter()
numberFormatter.locale = self.priceLocale
numberFormatter.numberStyle = .currency
return numberFormatter.string(from: self.price)
}
}
// MARK: Обновление информации
// в cell для 1, 6, 12 месяцев
extension PremiumRatesTVC {
func updateOneMonth(with product: SKProduct) {
let withCurrency = "\(product.priceLocale.currencyCode ?? " ")"
let daily = dailyPrice(from: Double(truncating: product.price), withMonth: 1.0)
oneMonthPriceLabel.text = "\(product.price) \(withCurrency)"
oneMothDailyPriceLabel.text = "\(daily) \(withCurrency)"
oneMonthPrice = "\(product.price) \(withCurrency)"
}
func updateSixMonth(with product: SKProduct) {
let withCurrency = "\(product.priceLocale.currencyCode ?? " ")"
let daily = dailyPrice(from: Double(truncating: product.price), withMonth: 6.0)
sixMonthPriceLabel.text = "\(product.price) \(withCurrency)"
sixMonthDailyPriceLabel.text = "\(daily) \(withCurrency)"
sixMonthPrice = "\(product.price) \(withCurrency)"
}
func updateTwelveMonth(with product: SKProduct) {
let withCurrency = "\(product.priceLocale.currencyCode ?? " ")"
let daily = dailyPrice(from: Double(truncating: product.price), withMonth: 12.0)
twelveMonthPriceLabel.text = "\(product.price) \(withCurrency)"
twelveMonthDailyPriceLabel.text = "\(daily) \(withCurrency)"
twelveMonthPrice = "\(product.price) \(withCurrency)"
}
func dailyPrice(from value: Double, withMonth: Double) -> String {
let days = withMonth * 30
let result = value / days
return String(format: "%.2f", result)
}
}
This image shows the testConfiguration.storekit file:
Also the image from the edit scheme:
also the file testConfiguration.storekit in the left menu with a question mark.
I hope I described the problem I encountered in detail and correctly. Many thanks to everyone who took the time.
I had this problem too. Try with a device on iOS 15.X.
Built with Xcode 14.0.1 iPhone 13 iOS 16.0: Skipping product because no price was available
Built with Xcode 14.0.1 iPhone 11 iOS 15.5: everything works.
I had the same problem and the same answers as #Vjardel, that this occurs on iOS 16 when started with Xcode. In my case I tested it with an iPad mini 5th generation on iOS 16 Beta 10.
Although, I discovered that this issues does not happen on the same device, if you try it with a TestFlight build. Therefore, you can test it with TestFlight, plus I assume that if the app is in the App Store the issue won't happen, as well.
My boss didn't have the Paid Apps field filled in. Be sure to look to make sure it is active.
Check this answer

HealthKit keeps updating the sample data on a simulator, but not the actual data on apple watch

I just started learning swift using WWDC open sources. Im learning on how to create watch os workout application. When I run this on the simulator it will keep updating sample data, but on my Apple Watch, when I run this, it doesn't keep updating the live workout data. I am sure that I have to deal with code below but
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return // Nothing to do.
}
let statistics = workoutBuilder.statistics(for: quantityType)
// Update the published values.
updateForStatistics(statistics)
}
}
}
I don't know exactly what goes on when invoking HealthKit, I took most of the code from the WWDC example.
import Foundation
import HealthKit
class WorkoutManager: NSObject, ObservableObject {
var selectedWorkout: HKWorkoutActivityType? {
didSet {
guard let selectedWorkout = selectedWorkout else { return }
startWorkout(workoutType: selectedWorkout)
}
}
#Published var showingSummaryView: Bool = false {
didSet {
if showingSummaryView == false {
resetWorkout()
}
}
}
let healthStore = HKHealthStore()
var session: HKWorkoutSession?
var builder: HKLiveWorkoutBuilder?
func startWorkout(workoutType: HKWorkoutActivityType) {
let configuration = HKWorkoutConfiguration()
configuration.activityType = workoutType
configuration.locationType = .outdoor
// Create the session and obtain the workout builder.
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
builder = session?.associatedWorkoutBuilder()
} catch {
// Handle any exceptions.
return
}
// Set the workout builder's data source.
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: configuration)
session?.delegate = self
builder?.delegate = self
// Start the workout session and begin data collection.
let startDate = Date()
session?.startActivity(with: startDate)
builder?.beginCollection(withStart: startDate) { (success, error) in
// The workout has started.
}
}
func requestAuthorization() {
// The quantity type to write to the health store.
let typesToShare: Set = [
HKQuantityType.workoutType()
]
// The quantity types to read from the health store.
let typesToRead: Set = [
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
HKObjectType.activitySummaryType()
]
// Request authorization for those quantity types.
healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { (success, error) in
// Handle error.
}
}
// MARK: - Session State Control
// The app's workout state.
#Published var running = false
func togglePause() {
if running == true {
self.pause()
} else {
resume()
}
}
func pause() {
session?.pause()
}
func resume() {
session?.resume()
}
func endWorkout() {
session?.end()
showingSummaryView = true
}
// MARK: - Workout Metrics
#Published var averageHeartRate: Double = 0
#Published var heartRate: Double = 0
#Published var workout: HKWorkout?
func updateForStatistics(_ statistics: HKStatistics?) {
guard let statistics = statistics else { return }
DispatchQueue.main.async {
switch statistics.quantityType {
case HKQuantityType.quantityType(forIdentifier: .heartRate):
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
self.heartRate = statistics.mostRecentQuantity()?.doubleValue(for: heartRateUnit) ?? 0
self.averageHeartRate = statistics.averageQuantity()?.doubleValue(for: heartRateUnit) ?? 0
default:
return
}
}
}
func resetWorkout() {
selectedWorkout = nil
builder = nil
workout = nil
session = nil
averageHeartRate = 0
heartRate = 0
}
}
extension WorkoutManager: HKWorkoutSessionDelegate {
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState, date: Date) {
DispatchQueue.main.async {
self.running = toState == .running
}
// Wait for the session to transition states before ending the builder.
if toState == .ended {
builder?.endCollection(withEnd: date) { (success, error) in
self.builder?.finishWorkout { (workout, error) in
DispatchQueue.main.async {
self.workout = workout
}
}
}
}
}
func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) {
}
}
extension WorkoutManager: HKLiveWorkoutBuilderDelegate {
func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {
}
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else {
return // Nothing to do.
}
let statistics = workoutBuilder.statistics(for: quantityType)
// Update the published values.
updateForStatistics(statistics)
}
}
}
Do you have the Workout processing background mode enabled in the Info.plist?

Firestore pagination using MVVM architecture swift

I don't quite understand what I am doing wrong since I am very new to MVVM. It worked in MVC architecture. I've setup my VM and am able to get the first set of results and even then that's not working properly. I get 4 results instead of 10 which is what LOADLIMIT is set as. I was able to get it to work in an MVC architecture without any issues. The VM function which triggers the query is called multiple (3) times instead of just once i.e. even prior to scrolling.
Here is my VM:
enum FetchRestaurant {
case success
case error
case location
case end
}
class ListViewModel {
let restaurant: [Restaurant]?
let db = Firestore.firestore()
var restaurantArray = [Restaurant]()
var lastDocument: DocumentSnapshot?
var currentLocation: CLLocation?
typealias fetchRestaurantCallback = (_ restaurants: [Restaurant]?, _ message: String?, _ status: FetchRestaurant) -> Void
var restaurantFetched: fetchRestaurantCallback?
var fetchRestaurant: FetchRestaurant?
init(restaurant: [Restaurant]) {
self.restaurant = restaurant
}
func fetchRestaurantCallback (callback: #escaping fetchRestaurantCallback) {
self.restaurantFetched = callback
}
func fetchRestaurants(address: String) {
print("address received: \(address)")
getLocation(from: address) { location in
if let location = location {
self.currentLocation = location
self.queryGenerator(at: location)
} else {
self.restaurantFetched?(nil, nil, .location)
}
}
}
func queryGenerator(at location: CLLocation) {
var query: Query!
if restaurantArray.isEmpty {
query = db.collection("Restaurant_Data").whereField("distributionType", isLessThanOrEqualTo: 2).limit(to: Constants.Mealplan.LOADLIMIT)
} else {
print("last document:\(String(describing: lastDocument?.documentID))")
query = db.collection("Restaurant_Data").whereField("distributionType", isLessThanOrEqualTo: 2).start(afterDocument: lastDocument!).limit(to: Constants.Mealplan.LOADLIMIT)
}
batchFetch(query: query)
}
func batchFetch(query: Query) {
query.getDocuments { (querySnapshot, error) in
if let error = error {
self.restaurantFetched?(nil, error.localizedDescription, .error)
} else if querySnapshot!.isEmpty {
self.restaurantFetched?(nil, nil, .end)
} else if !querySnapshot!.isEmpty {
let queriedRestaurants = querySnapshot?.documents.compactMap { querySnapshot -> Restaurant? in
return try? querySnapshot.data(as: Restaurant.self)
}
guard let restaurants = queriedRestaurants,
let currentLocation = self.currentLocation else {
self.restaurantFetched?(nil, nil, .end)
return }
self.restaurantArray.append(contentsOf: self.applicableRestaurants(allQueriedRestaurants: restaurants, location: currentLocation))
DispatchQueue.main.asyncAfter(deadline: .now(), execute: {
self.restaurantFetched?(self.restaurantArray, nil, .success)
})
self.lastDocument = querySnapshot!.documents.last
}
}
}
func getLocation(from address: String, completionHandler: #escaping (_ location: CLLocation?) -> Void) {
let geocoder = CLGeocoder()
geocoder.geocodeAddressString(address) { (placemarks, error) in
guard let placemarks = placemarks,
let location = placemarks.first?.location else {
completionHandler(nil)
return
}
completionHandler(location)
}
}
}
And in the VC viewDidLoad:
var fetchMore = false
var reachedEnd = false
let leadingScreensForBatching: CGFloat = 5.0
var searchController = UISearchController(searchResultsController: nil)
var currentAddress : String?
var listViewModel = ListViewModel(restaurant: [Restaurant]())
override func viewDidLoad() {
super.viewDidLoad()
listViewModel.fetchRestaurantCallback { (restaurants, error, result) in
switch result {
case .success :
self.loadingShimmer.stopShimmering()
self.loadingShimmer.removeFromSuperview()
guard let fetchedRestaurants = restaurants else { return }
self.restaurantArray.append(contentsOf: fetchedRestaurants)
self.tableView.reloadData()
self.fetchMore = false
case .location :
self.showAlert(alertTitle: "No businesses nearby", message: "Try going back and changing the address")
case .error :
guard let error = error else { return }
self.showAlert(alertTitle: "Error", message: error)
case .end :
self.fetchMore = false
self.reachedEnd = true
}
}
if let currentAddress = currentAddress {
listViewModel.fetchRestaurants(address: currentAddress)
}
}
I would really appreciate links or resources for implementing MVVM in Swift for a Firestore back-end. I'm coming up short on searches here and on Google. Even tried medium.
EDIT
class ListViewController: UITableViewController {
lazy var loadingShimmer: UIImageView = {
let image = UIImage(named: "shimmer_background")
let imageview = UIImageView(image: image)
imageview.contentMode = .top
imageview.translatesAutoresizingMaskIntoConstraints = false
return imageview
}()
var restaurantArray = [Restaurant]()
var planDictionary = [String: Any]()
var fetchMore = false
var reachedEnd = false
let leadingScreensForBatching: CGFloat = 5.0
var searchController = UISearchController(searchResultsController: nil)
var currentAddress : String?
var listViewModel = ListViewModel(restaurant: [Restaurant]())
override func viewDidLoad() {
super.viewDidLoad()
setupTable()
}
override func viewWillAppear(_ animated: Bool) {
clearsSelectionOnViewWillAppear = false
}
func setupTable() {
navigationItem.backBarButtonItem = UIBarButtonItem(title: "Restaurant", style: .plain, target: nil, action: nil)
tableView.register(RestaurantCell.self, forCellReuseIdentifier: "Cell")
tableView.delegate = self
tableView.dataSource = self
let navigationBarHeight: CGFloat = self.navigationController!.navigationBar.frame.height
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: -navigationBarHeight, right: 0)
tableView.separatorStyle = .none
tableView.showsVerticalScrollIndicator = false
tableView.addSubview(loadingShimmer)
loadingShimmer.topAnchor.constraint(equalTo: tableView.safeAreaLayoutGuide.topAnchor).isActive = true
loadingShimmer.leadingAnchor.constraint(equalTo: tableView.leadingAnchor).isActive = true
loadingShimmer.trailingAnchor.constraint(equalTo: tableView.trailingAnchor).isActive = true
loadingShimmer.startShimmering()
initialSetup()
}
func initialSetup() {
let addressOne = planDictionary["addressOne"] as! String + ", "
let city = planDictionary["city"] as! String + ", "
let postalCode = planDictionary["postalCode"] as! String
currentAddress = addressOne + city + postalCode
setupSearch()
listViewModel.fetchRestaurantCallback { (restaurants, error, result) in
switch result {
case .success :
self.loadingShimmer.stopShimmering()
self.loadingShimmer.removeFromSuperview()
guard let fetchedRestaurants = restaurants else { return }
self.restaurantArray.append(contentsOf: fetchedRestaurants)
self.tableView.reloadData()
self.fetchMore = false
case .location :
self.showAlert(alertTitle: "No businesses nearby", message: "Try going back and changing the address")
case .error :
guard let error = error else { return }
self.showAlert(alertTitle: "Error", message: error)
case .end :
self.fetchMore = false
self.reachedEnd = true
}
}
if let currentAddress = currentAddress {
listViewModel.fetchRestaurants(address: currentAddress)
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let off = scrollView.contentOffset.y
let off1 = scrollView.contentSize.height
if off > off1 - scrollView.frame.height * leadingScreensForBatching {
print("\(fetchMore), \(reachedEnd)")
if !fetchMore && !reachedEnd {
if let address = self.currentAddress {
print("address sent: \(address)")
listViewModel.fetchRestaurants(address: address)
}
}
}
}
}
That you're only getting back 4 results instead of 10 is not due to a faulty query or get-document request—those are coded properly. You're either losing documents when you parse them (some are failing Restaurant initialization), Constants.Mealplan.LOADLIMIT is wrong, or there aren't more than 4 documents in the collection itself that satisfy the query.
That the query is executed 3 times instead of once is also not due to anything in this code—viewDidLoad is only called once and geocodeAddressString only returns once. You're making a fetch request elsewhere that we can't see.
In the batchFetch method, you have a guard that returns out of the function without ever calling its completion handler. This will leave the UI in a state of limbo. I'd recommend always calling the completion handler no matter why the function returns.
You never manage the document cursor. If the get-document return has less documents than the load limit, then nil the last-document cursor. This way, when you attempt to get the next page of documents, guard against a nil cursor and see if there is even more to fetch.
There's no need to pass in an empty array and have your function fill it; simply construct and return an array of results within ListViewModel itself.
We can't see how you trigger pagination. Is it through a scroll delegate when the user reaches the bottom or through a button tap, for example? If it's through a scroll delegate, then I'd disable that for now and see how many returns you get—I suspect one, instead of 3.
What is the particular reason you've ditched MVC for MVVM here? With MVC, you can get pagination up with just a few lines of code. I think MVVM is overkill for iOS applications and would advise against using it unless you have a compelling reason.

Write unit tests for ObservableObject ViewModels with Published results

Today again one combine problem I currently run in and I hope that someone of you can help. How can normal unit tests be written for ObservableObjects classes which contain #Published attributes? How can I subscribe in my test to them to get the result object which I can assert?
The injected mock for the web service works correctly, loadProducts() function set exactly the same elements from the mock in the fetchedProducts array.
But I don't know currently how to access this array in my test after it is filled by the function because it seems that I cannot work with expectations here, loadProducts() has no completion block.
The code looks like this:
class ProductsListViewModel: ObservableObject {
let getRequests: GetRequests
let urlService: ApiUrls
private let networkUtils: NetworkRequestUtils
let productsWillChange = ObservableObjectPublisher()
#Published var fetchedProducts = [ProductDTO]()
#Published var errorCodeLoadProducts: Int?
init(getRequestsHelper: GetRequests, urlServiceClass: ApiUrls = ApiUrls(), utilsNetwork: NetworkRequestUtils = NetworkRequestUtils()) {
getRequests = getRequestsHelper
urlService = urlServiceClass
networkUtils = utilsNetwork
}
// nor completion block in the function used
func loadProducts() {
let urlForRequest = urlService.loadProductsUrl()
getRequests.getJsonData(url: urlForRequest) { [weak self] (result: Result<[ProductDTO], Error>) in
self?.isLoading = false
switch result {
case .success(let productsArray):
// the products filled async here
self?.fetchedProducts = productsArray
self?.errorCodeLoadProducts = nil
case .failure(let error):
let errorCode = self?.networkUtils.errorCodeFrom(error: error)
self?.errorCodeLoadProducts = errorCode
print("error: \(error)")
}
}
}
}
The test I try to write looks like this at the moment:
import XCTest
#testable import MyProject
class ProductsListViewModelTest: XCTestCase {
var getRequestMock: GetRequests!
let requestManagerMock = RequestManagerMockLoadProducts()
var productListViewModel: ProductsListViewModel!
override func setUp() {
super.setUp()
getRequestMock = GetRequests(networkHelper: requestManagerMock)
productListViewModel = ProductsListViewModel(getRequestsHelper: getRequestMock)
}
func test_successLoadProducts() {
let loginDto = LoginResponseDTO(token: "token-token")
UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)
productListViewModel.loadProducts()
// TODO access the fetchedProducts here somehow and assert them
}
}
The Mock looks like this:
class RequestManagerMockLoadProducts: NetworkRequestManagerProtocol {
var isSuccess = true
func makeNetworkRequest<T>(urlRequestObject: URLRequest, completion: #escaping (Result<T, Error>) -> Void) where T : Decodable {
if isSuccess {
let successResultDto = returnedProductedArray() as! T
completion(.success(successResultDto))
} else {
let errorString = "Cannot create request object here"
let error = NSError(domain: ErrorDomainDescription.networkRequestDomain.rawValue, code: ErrorDomainCode.unexpectedResponseFromAPI.rawValue, userInfo: [NSLocalizedDescriptionKey: errorString])
completion(.failure(error))
}
}
func returnedProductedArray() -> [ProductDTO] {
let product1 = ProductDTO(idFromBackend: "product-1", name: "product-1", description: "product-description", price: 3.55, photo: nil)
let product2 = ProductDTO(idFromBackend: "product-2", name: "product-2", description: "product-description-2", price: 5.55, photo: nil)
let product3 = ProductDTO(idFromBackend: "product-3", name: "product-3", description: "product-description-3", price: 8.55, photo: nil)
return [product1, product2, product3]
}
}
Maybe this article can help you
Testing your Combine Publishers
To solve your issue I will use code from my article
typealias CompetionResult = (expectation: XCTestExpectation,
cancellable: AnyCancellable)
func expectValue<T: Publisher>(of publisher: T,
timeout: TimeInterval = 2,
file: StaticString = #file,
line: UInt = #line,
equals: [(T.Output) -> Bool])
-> CompetionResult {
let exp = expectation(description: "Correct values of " + String(describing: publisher))
var mutableEquals = equals
let cancellable = publisher
.sink(receiveCompletion: { _ in },
receiveValue: { value in
if mutableEquals.first?(value) ?? false {
_ = mutableEquals.remove(at: 0)
if mutableEquals.isEmpty {
exp.fulfill()
}
}
})
return (exp, cancellable)
}
your test needs to use this function
func test_successLoadProducts() {
let loginDto = LoginResponseDTO(token: "token-token")
UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)
/// The expectation here can be extended as needed
let exp = expectValue(of: productListViewModel .$fetchedProducts.eraseToAnyPublisher(), equals: [{ $0[0].idFromBackend == "product-1" }])
productListViewModel.loadProducts()
wait(for: [exp.expectation], timeout: 1)
}
The easy and clearest way for me is simply to test #published var after X seconds. An example bellow :
func test_successLoadProducts() {
let loginDto = LoginResponseDTO(token: "token-token")
UserDefaults.standard.save(loginDto, forKey: CommonConstants.persistedLoginObject)
productListViewModel.loadProducts()
// TODO access the fetchedProducts here somehow and assert them
let expectation = XCTestExpectation()
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
XCTAssertEqual(self.productListViewModel.fetchedProducts, ["Awaited values"])
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
}
I hope that helps !

Delayed Load of Products in Store Swift v3

Currently working on submitting my application to Apple, and I'm experiencing a strange issue where my product identifiers are not loading immediately. Sometimes if you load the store quick enough, the prices are not loaded and you can't make a purchase. Apple is reporting that they can't make a purchase on their ipv6 network, but I've also seen this issue on ipv4.
Do you see any logic issues which would be causing this issue and/or ipv6 non-friendly code? Thank you.
Code Follows:
IAPManager:
import Foundation
import StoreKit
import RealmSwift
import AVFoundation
class IAPManager: NSObject, SKProductsRequestDelegate, SKPaymentTransactionObserver
{
static let sharedInstance = IAPManager()
private let realm = try! Realm()
// SKRequest
var request:SKProductsRequest!
// Array of SKProducts
var products:[SKProduct] = []
// Audio
var audioPlayer: AVAudioPlayer!
// Received Response From Store
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
// Check for Response
self.products = response.products
}
// Creates String Array of Product Identifiers
func getStoreProductIdentifiers() -> [String]
{
var identifiers: [String] = []
let hintsPackXS = "net.identitywithheld.HintsPackXS"
let hintsPackS = "net.identitywithheld.HintsPackS"
let hintsPackM = "net.identitywithheld.HintsPackM"
let hintsPackL = "net.identitywithheld.HintsPackL"
let hintsPackXL = "net.identitywithheld.HintsPackXL"
let removeAds = "net.identitywithheld.RemoveAds"
identifiers.append(hintsPackXS)
identifiers.append(hintsPackS)
identifiers.append(hintsPackM)
identifiers.append(hintsPackL)
identifiers.append(hintsPackXL)
identifiers.append(removeAds)
return identifiers
}
// Perform Request with Identifiers
func performProductRequestForIdentifiers(identifiers:[String]){
// Create Set Out of String Array and Type Cast as Set<String>
// Note that sets are not in any paritcular order.
let products = NSSet(array: identifiers) as! Set<String>
// Set request to call based on products identifier
self.request = SKProductsRequest(productIdentifiers: products)
// Set Delegate to self (this class)
self.request.delegate = self
// Start Request
self.request.start()
}
// Request Store Products
// Gets Identifiers and Performs Requests (Ties Together)
func requestStoreProducts(){
self.performProductRequestForIdentifiers(identifiers: self.getStoreProductIdentifiers())
}
// Checks to see if user can make purchases
func setupPurchases() -> Bool
{
// Check to See if Can Make Payments
if SKPaymentQueue.canMakePayments(){
// Sets Self As Observer
SKPaymentQueue.default().add(self)
return true
}
else
{
return false
}
}
// Requests Payment Request
func createPaymentRequestForProduct(product:SKProduct)
{
let payment = SKMutablePayment(product: product)
payment.quantity = 1
// Starts Payment Process
SKPaymentQueue.default().add(payment)
}
// Payment Observer - Is Updated Whenever There's An Update
//MARK - Transaction Observer
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
// For Each Transaction in Transaction Array [SKPaymentTransaction]
for transaction in transactions {
switch transaction.transactionState{
case .purchasing:
print("purchasing")
break
case .purchased:
print("purchased")
complete(transaction: transaction)
queue.finishTransaction(transaction) // Marks as Finished to Remove from Queue
break
case .deferred:
print("deferred")
break
case .failed:
print("failed")
fail(transaction: transaction)
queue.finishTransaction(transaction) // Marks as Finished to Remove from Queue
break
case .restored:
print("restored")
restore(transaction: transaction)
queue.finishTransaction(transaction) // Marks as Finished to Remove from Queue
break
}
}
}
// Complete Purchase
private func complete(transaction: SKPaymentTransaction) {
print("complete...")
deliverPurchaseNotificationFor(identifier: transaction.payment.productIdentifier)
// Check Whether Sounds Are On
let gameDataObj = realm.objects(GameData.self).filter("id == 0").first
let soundOn = gameDataObj!.sound
if(soundOn == true)
{
let hintSound = NSURL(fileURLWithPath: Bundle.main.path(forResource: "purchaseComplete", ofType: "caf")!)
try? AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback)
try? AVAudioSession.sharedInstance().setActive(true)
try? audioPlayer = AVAudioPlayer(contentsOf: hintSound as URL)
audioPlayer.volume = 0.5
audioPlayer!.prepareToPlay()
audioPlayer!.play()
}
// If On, Play Sound
}
// Restore Purchases
private func restore(transaction: SKPaymentTransaction) {
guard let productIdentifier = transaction.original?.payment.productIdentifier else { return }
print("restore... \(productIdentifier)")
deliverPurchaseNotificationFor(identifier: productIdentifier)
SKPaymentQueue.default().finishTransaction(transaction)
}
private func fail(transaction: SKPaymentTransaction) {
print("fail...")
if let transactionError = transaction.error as? NSError {
if transactionError.code != SKError.paymentCancelled.rawValue {
print("Transaction Error: \(transaction.error?.localizedDescription)")
}
}
SKPaymentQueue.default().finishTransaction(transaction)
}
private func deliverPurchaseNotificationFor(identifier: String?) {
guard let identifier = identifier else { return }
let gameDataObj = realm.objects(GameData.self).filter("id == 0")
var hints = gameDataObj.first!.hints
var hintsPurchase = false
if(identifier == "net.identitywithheld.HintsPackXS")
{
print("Extra Small Pack")
hints = hints + 10
hintsPurchase = true
}
else if(identifier == "net.identitywithheld.HintsPackS")
{
print("Small Pack")
hints = hints + 45
hintsPurchase = true
}
else if(identifier == "net.identitywithheld.HintsPackM")
{
print("Medium Pack")
hints = hints + 125
hintsPurchase = true
}
else if(identifier == "net.identitywithheld.HintsPackL")
{
print("Large Pack")
hints = hints + 200
hintsPurchase = true
}
else if(identifier == "net.identitywithheld.HintsPackXL")
{
print("Extra Large Pack")
hints = hints + 500
hintsPurchase = true
}
else if(identifier == "net.identitywithheld.RemoveAds")
{
print("Remove Ads")
try! realm.write {
gameDataObj.first!.ads = false
}
}
// If Hint Purchase, Set Hints
if(hintsPurchase == true)
{
try! realm.write {
gameDataObj.first!.hints = hints
}
}
}
func getTextWidth(font: UIFont, text: String) -> CGFloat
{
let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: 17, height: 17))
label.text = text
label.font = font
label.sizeToFit()
return label.frame.width
}
func getTextHeight(font: UIFont, text: String, width: CGFloat) -> CGFloat
{
let label:UILabel = UILabel(frame: CGRect(x: 0, y: 0, width: width, height: 17))
label.text = text
label.font = font
label.numberOfLines = 0
label.sizeToFit()
return label.frame.height
}
}
App Delegate
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
openRealm()
// Override point for customization after application launch.
// Use Firebase library to configure APIs
FIRApp.configure()
GADMobileAds.configure(withApplicationID: adMobAppID)
// Store Call to Get Products if Can Make Purchases
if (IAPManager.sharedInstance.setupPurchases() == true)
{
IAPManager.sharedInstance.requestStoreProducts()
UserDefaults.standard.set(true, forKey: "IAPCapable")
UserDefaults.standard.synchronize()
}
else
{
UserDefaults.standard.set(false, forKey: "IAPCapable")
UserDefaults.standard.synchronize()
}
// Initialize the Chartboost library
Chartboost.start(withAppId: "identitywithheld", appSignature: "identitywithheld", delegate: nil)
return true
}
After testing this vigorously, I realized that I wasn't delaying the store load and so therefore the products were not fully loaded. This isn't an ipv6 issue.
To resolve this issue, I added an activity indicator and implemented a timer to check and see if the products were loaded before displaying the store.