Skip to content

Commit

Permalink
Merge pull request #78 from Hengyu/main
Browse files Browse the repository at this point in the history
Use system format for subscription price display
  • Loading branch information
russell-archer authored May 5, 2024
2 parents e7fd228 + b289059 commit 2e73d0b
Show file tree
Hide file tree
Showing 2 changed files with 111 additions and 62 deletions.
76 changes: 51 additions & 25 deletions Sources/StoreHelper/Core/PrePurchaseSubscriptionInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,59 @@ public struct PurchasePriceForDisplay: Hashable, Identifiable {
@available(iOS 15.0, macOS 12.0, *)
public struct PrePurchaseSubscriptionInfo: Hashable {

/// The product's unique id.
public var productId: ProductId

/// The product's display name.
public var name: String

/// Localized standard (non-offer) price (e.g. "$1.99").
public var purchasePrice: String?

/// The standard (non-offer) renewal period (e.g. "/ month").
public var renewalPeriod: String?

/// The product for the subscription.
public let product: Product

/// The duration that this subscription lasts before auto-renewing.
public let subscriptionPeriod: Product.SubscriptionPeriod

/// Info on the introductory offer, if any.
public var introductoryOffer: SubscriptionOfferInfo?
public let introductoryOffer: SubscriptionOfferInfo?

/// Info on any promotional offers. Should only be presented as an upgrade to users with a current subscription, or lapsed subscribers.
public var promotionalOffers: [SubscriptionOfferInfo]?
public let promotionalOffers: [SubscriptionOfferInfo]

/// True if the `introductoryOffer` property may be presented to the user, false otherwise.
public var introductoryOfferEligible = false
public internal(set) var introductoryOfferEligible = false

/// True if the `promotionalOffers` property contains eligible offers for the user, false otherwise.
public var promotionalOffersEligible = false

public internal(set) var promotionalOffersEligible = false

/// The unique product identifier.
public var productId: String {
product.id
}

/// A localized display name of the product.
public var name: String {
product.displayName
}

/// A localized string representation of `price`.
public var purchasePrice: String {
product.displayPrice
}

/// The price text for display (e.g. "$0.99 / 1 month", "$1.99 / 2 months").
public var displayPrice: String {
var formatStyle = product.subscriptionPeriodFormatStyle
formatStyle.style = .wide
formatStyle.locale = .current
return "\(purchasePrice) / \(subscriptionPeriod.formatted(formatStyle))"
}

public init(
product: Product,
subscriptionPeriod: Product.SubscriptionPeriod,
introductoryOffer: SubscriptionOfferInfo?,
promotionalOffers: [SubscriptionOfferInfo]
) {
self.product = product
self.subscriptionPeriod = subscriptionPeriod
self.introductoryOffer = introductoryOffer
self.promotionalOffers = promotionalOffers
}

/// An array of promo ids and localized purchase prices and renewal periods in a format that may be displayed to the user.
/// Displays the correct price and renewal period for either the standard price, an eligible promotional price, or an
/// eligible introductory price. Use this property in preference to other price and renewal period values as it will return
Expand All @@ -56,21 +85,18 @@ public struct PrePurchaseSubscriptionInfo: Hashable {
/// single element with the promo id (which will be nil) and price/period for the offer.
/// - If there are no offers, the value of this property will be an array with a single element with the standard price/period.
public var purchasePriceForDisplay: [PurchasePriceForDisplay]? {
if promotionalOffersEligible, let promotionalOffers, promotionalOffers.count > 0 {
if promotionalOffersEligible, !promotionalOffers.isEmpty {
var offersDisplay = [PurchasePriceForDisplay]()
promotionalOffers.forEach { offer in
if let offerDisplay = offer.offerDisplay {
offersDisplay.append(PurchasePriceForDisplay(id: offer.id, price: offerDisplay))
}
}

return offersDisplay // Promotional offers

} else if introductoryOfferEligible, let introductoryOffer, let offerDisplay = introductoryOffer.offerDisplay {
return [PurchasePriceForDisplay(id: nil, price: offerDisplay)] // Introductory offers always have nil ids
}
else {
return [PurchasePriceForDisplay(id: nil, price: "\(purchasePrice ?? "?? price") \(renewalPeriod ?? "?? period")")] // Standard price
} else {
return [PurchasePriceForDisplay(id: nil, price: displayPrice)] // Standard price
}
}
}
Expand Down
97 changes: 60 additions & 37 deletions Sources/StoreHelper/ViewModels/PriceViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,17 @@ public struct PriceViewModel {
@MainActor public func getPrePurchaseSubscriptionInfo(productId: ProductId) async -> PrePurchaseSubscriptionInfo? {
guard let product = storeHelper.product(from: productId), let subscription = product.subscription else { return nil }

var ppSubInfo = PrePurchaseSubscriptionInfo(productId: productId, name: product.displayName)
ppSubInfo.purchasePrice = product.displayPrice
ppSubInfo.renewalPeriod = "/ \(periodText(unit: subscription.subscriptionPeriod.unit, value: subscription.subscriptionPeriod.value))" // e.g. "$1.99 / month"
ppSubInfo.introductoryOffer = processIntroductoryOffer(sub: subscription, for: productId)
ppSubInfo.promotionalOffers = processPromotionalOffers(sub: subscription, for: productId)
var ppSubInfo = PrePurchaseSubscriptionInfo(
product: product,
subscriptionPeriod: subscription.subscriptionPeriod,
introductoryOffer: processIntroductoryOffer(sub: subscription, for: product),
promotionalOffers: processPromotionalOffers(sub: subscription, for: product)
)

ppSubInfo.promotionalOffersEligible = false
ppSubInfo.introductoryOfferEligible = false
// Are there one or more promotional offers, or an introductory offer available?
if ppSubInfo.promotionalOffers != nil {
if !ppSubInfo.promotionalOffers.isEmpty {
// Promotional offers take precedence over introductory offers. There are promo offers available, but is this user eligible?
if await storeHelper.subscriptionHelper.isLapsedSubscriber(to: product) {
ppSubInfo.promotionalOffersEligible = true
Expand All @@ -70,76 +71,98 @@ public struct PriceViewModel {

// MARK: - Private methods

private func processIntroductoryOffer(sub: Product.SubscriptionInfo, for productId: ProductId) -> SubscriptionOfferInfo? {
private func processIntroductoryOffer(sub: Product.SubscriptionInfo, for product: Product) -> SubscriptionOfferInfo? {
guard let introOffer = sub.introductoryOffer else { return nil } // Get the introductory offer for this particular subscription
var introOfferInfo = SubscriptionOfferInfo(id: introOffer.id, productId: productId)
var introOfferInfo = SubscriptionOfferInfo(id: introOffer.id, productId: product.id)
introOfferInfo.offerType = introOffer.type
introOfferInfo.paymentMode = introOffer.paymentMode
introOfferInfo.paymentModeDisplay = paymentModeDisplay(mode: introOffer.paymentMode)
introOfferInfo.offerPrice = introOffer.displayPrice
introOfferInfo.offerPeriodDisplay = "\(introOffer.period.value) \(periodText(unit: introOffer.period.unit, value: introOffer.period.value))"
introOfferInfo.offerPeriodDisplay = "\(introOffer.period.value) \(periodText(introOffer.period, product: product))"
introOfferInfo.offerPeriodUnit = introOffer.period.unit
introOfferInfo.offerPeriodValue = introOffer.period.value
introOfferInfo.offerPeriodCount = introOffer.periodCount
introOfferInfo.offerDisplay = createOfferDisplay(for: introOffer.paymentMode, price: introOffer.displayPrice, periodUnit: introOffer.period.unit, periodValue: introOffer.period.value, periodCount: introOffer.periodCount, offerType: introOffer.type)
introOfferInfo.offerDisplay = createOfferDisplay(for: introOffer.paymentMode, product: product, price: introOffer.displayPrice, period: introOffer.period, periodCount: introOffer.periodCount, offerType: introOffer.type)

return introOfferInfo
}

private func processPromotionalOffers(sub: Product.SubscriptionInfo, for productId: ProductId) -> [SubscriptionOfferInfo] {
private func processPromotionalOffers(sub: Product.SubscriptionInfo, for product: Product) -> [SubscriptionOfferInfo] {
let promoOffers = sub.promotionalOffers // Gets all the promotional offers defined for this particular subscription
var promoOfferInfoArray = [SubscriptionOfferInfo]()
guard !promoOffers.isEmpty else { return promoOfferInfoArray }

promoOffers.forEach { promoOffer in
var promoOfferInfo = SubscriptionOfferInfo(id: promoOffer.id, productId: productId)
var promoOfferInfo = SubscriptionOfferInfo(id: promoOffer.id, productId: product.id)
promoOfferInfo.offerType = promoOffer.type
promoOfferInfo.paymentMode = promoOffer.paymentMode
promoOfferInfo.paymentModeDisplay = paymentModeDisplay(mode: promoOffer.paymentMode)
promoOfferInfo.offerPrice = promoOffer.displayPrice
promoOfferInfo.offerPeriodDisplay = "\(promoOffer.period.value) \(periodText(unit: promoOffer.period.unit, value: promoOffer.period.value))"
promoOfferInfo.offerPeriodDisplay = "\(promoOffer.period.value) \(periodText(promoOffer.period, product: product))"
promoOfferInfo.offerPeriodUnit = promoOffer.period.unit
promoOfferInfo.offerPeriodValue = promoOffer.period.value
promoOfferInfo.offerPeriodCount = promoOffer.periodCount
promoOfferInfo.offerDisplay = createOfferDisplay(for: promoOffer.paymentMode, price: promoOffer.displayPrice, periodUnit: promoOffer.period.unit, periodValue: promoOffer.period.value, periodCount: promoOffer.periodCount, offerType: promoOffer.type)
promoOfferInfo.offerDisplay = createOfferDisplay(for: promoOffer.paymentMode, product: product, price: promoOffer.displayPrice, period: promoOffer.period, periodCount: promoOffer.periodCount, offerType: promoOffer.type)

promoOfferInfoArray.append(promoOfferInfo)
}

return promoOfferInfoArray
}

private func createOfferDisplay(for paymentMode: Product.SubscriptionOffer.PaymentMode,
product: Product,
price: String,
periodUnit: Product.SubscriptionPeriod.Unit,
periodValue: Int,
period: Product.SubscriptionPeriod,
periodCount: Int,
offerType: Product.SubscriptionOffer.OfferType) -> String? {

switch paymentMode {
case .payAsYouGo: return "\(periodCount) \(periodText(unit: periodUnit, value: periodCount)) at\n \(offerType == .introductory ? "an introductory" : "a promotional") price of\n \(price) per \(periodText(unit: periodUnit, value: 1))"
case .payUpFront: return "\(periodValue) \(periodText(unit: periodUnit, value: periodValue)) at\n \(offerType == .introductory ? "an introductory" : "a promotional") price of\n \(price)"
case .freeTrial: return "\(periodValue) \(periodText(unit: periodUnit, value: periodValue))\n\(offerType == .introductory ? "free trial" : "promotional period at no charge")"
default: return nil
case .payAsYouGo:
return "\(periodCount) \(periodUnitText(period.unit, product: product)) at\n \(offerType == .introductory ? "an introductory" : "a promotional") price of\n \(price) per \(periodUnitText(period.unit, product: product))"
case .payUpFront:
return "\(periodText(period, product: product)) at\n \(offerType == .introductory ? "an introductory" : "a promotional") price of\n \(price)"
case .freeTrial:
return "\(periodText(period, product: product))\n\(offerType == .introductory ? "free trial" : "promotional period at no charge")"
default:
return nil
}
}

private func periodText(unit: Product.SubscriptionPeriod.Unit, value: Int) -> String {
switch unit {
case .day: return value > 1 ? "days" : "day"
case .week: return value > 1 ? "weeks" : "week"
case .month: return value > 1 ? "months" : "month"
case .year: return value > 1 ? "years" : "year"
@unknown default: return value > 1 ? "months" : "month"

private func periodUnitText(_ unit: Product.SubscriptionPeriod.Unit, product: Product) -> String {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, visionOS 1.0, *) {
let format = product.subscriptionPeriodUnitFormatStyle.locale(.current)
return unit.formatted(format)
} else if #available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.6, *) {
return unit.localizedDescription
} else {
switch unit {
case .day: return "day"
case .week: return "week"
case .month: return "month"
case .year: return "year"
@unknown default: return "unknown"
}
}
}

private func periodText(_ period: Product.SubscriptionPeriod, product: Product) -> String {
var format = product.subscriptionPeriodFormatStyle
format.style = .wide
format.locale = .current
return period.formatted(format)
}

private func paymentModeDisplay(mode: Product.SubscriptionOffer.PaymentMode) -> String {
switch mode {
case .freeTrial: return "Free trial"
case .payAsYouGo: return "Pay as you go"
case .payUpFront: return "Pay up front"
default: return "Unknown"
if #available(iOS 15.4, macOS 12.3, tvOS 15.4, watchOS 8.5, visionOS 1.0, *) {
mode.localizedDescription
} else {
switch mode {
case .freeTrial: "Free trial"
case .payAsYouGo: "Pay as you go"
case .payUpFront: "Pay up front"
default: "Unknown"
}
}
}
}

0 comments on commit 2e73d0b

Please sign in to comment.