Skip to content

Commit

Permalink
Paywalls: added new total_price_and_per_month variable (#2845)
Browse files Browse the repository at this point in the history
This also expands `Localization` support to be able to format this in
any language.
  • Loading branch information
NachoSoto committed Jul 31, 2023
1 parent 0d59ce6 commit 7f82935
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 103 deletions.
141 changes: 141 additions & 0 deletions RevenueCatUI/Data/Localization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
//
// Localization.swift
//
//
// Created by Nacho Soto on 7/20/23.
//

import Foundation
import RevenueCat

enum Localization {

/// - Returns: an appropriately short abbreviation for the given `unit`.
static func abbreviatedUnitLocalizedString(
for unit: NSCalendar.Unit,
locale: Locale = .current
) -> String {
let (full, abbreviated) = self.unitLocalizedString(for: unit, locale: locale)

if full.count <= Self.unitAbbreviationMaximumLength {
return full
} else {
return abbreviated
}
}

static func localizedDuration(
for subscriptionPeriod: SubscriptionPeriod,
locale: Locale = .current
) -> String {
let formatter = DateComponentsFormatter()
formatter.calendar?.locale = locale
formatter.allowedUnits = [subscriptionPeriod.unit.calendarUnit]
formatter.unitsStyle = .full
formatter.includesApproximationPhrase = false
formatter.includesTimeRemainingPhrase = false
formatter.maximumUnitCount = 1

return formatter.string(from: subscriptionPeriod.components) ?? ""
}

}

// MARK: - Private

private extension Localization {

static func unitLocalizedString(
for unit: NSCalendar.Unit,
locale: Locale = .current
) -> (full: String, abbreviated: String) {
var calendar: Calendar = .current
calendar.locale = locale

let date = Date()
let value = 1
let component = unit.component

guard let sinceUnits = calendar.date(byAdding: component,
value: value,
to: date) else { return ("", "") }

let formatter = DateComponentsFormatter()
formatter.calendar = calendar
formatter.allowedUnits = [unit]

func result(for style: DateComponentsFormatter.UnitsStyle) -> String {
formatter.unitsStyle = style
guard let string = formatter.string(from: date, to: sinceUnits) else { return "" }

return string
.replacingOccurrences(of: String(value), with: "")
.trimmingCharacters(in: .whitespaces)
}

return (full: result(for: .full),
abbreviated: result(for: .abbreviated))
}

static let unitAbbreviationMaximumLength = 3

}

// MARK: - Extensions

private extension NSCalendar.Unit {

var component: Calendar.Component {
switch self {
case .era: return .era
case .year: return .year
case .month: return .month
case .day: return .day
case .hour: return .hour
case .minute: return .minute
case .second: return .second
case .weekday: return .weekday
case .weekdayOrdinal: return .weekdayOrdinal
case .quarter: return .quarter
case .weekOfMonth: return .weekOfMonth
case .weekOfYear: return .weekOfYear
case .yearForWeekOfYear: return .yearForWeekOfYear
case .nanosecond: return .nanosecond
case .calendar: return .calendar
case .timeZone: return .timeZone
default: return .calendar
}
}
}

private extension SubscriptionPeriod.Unit {

var calendarUnit: NSCalendar.Unit {
switch self {
case .day: return .day
case .week: return .weekOfMonth
case .month: return .month
case .year: return .year
}
}

}

private extension SubscriptionPeriod {

var components: DateComponents {
switch self.unit {
case .day:
return DateComponents(day: self.value)
case .week:
return DateComponents(weekOfMonth: self.value)
case .month:
return DateComponents(month: self.value)
case .year:
return DateComponents(year: self.value)
@unknown default:
return .init()
}
}

}
18 changes: 11 additions & 7 deletions RevenueCatUI/Data/ProcessedLocalizedConfiguration.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import RevenueCat

/// A `PaywallData.LocalizedConfiguration` with processed variables
Expand All @@ -13,15 +14,18 @@ struct ProcessedLocalizedConfiguration: PaywallLocalizedConfiguration {

init(
_ configuration: PaywallData.LocalizedConfiguration,
_ dataProvider: VariableDataProvider
_ dataProvider: VariableDataProvider,
_ locale: Locale
) {
self.init(
title: configuration.title.processed(with: dataProvider),
subtitle: configuration.subtitle.processed(with: dataProvider),
callToAction: configuration.callToAction.processed(with: dataProvider),
callToActionWithIntroOffer: configuration.callToActionWithIntroOffer?.processed(with: dataProvider),
offerDetails: configuration.offerDetails.processed(with: dataProvider),
offerDetailsWithIntroOffer: configuration.offerDetailsWithIntroOffer?.processed(with: dataProvider)
title: configuration.title.processed(with: dataProvider, locale: locale),
subtitle: configuration.subtitle.processed(with: dataProvider, locale: locale),
callToAction: configuration.callToAction.processed(with: dataProvider, locale: locale),
callToActionWithIntroOffer: configuration.callToActionWithIntroOffer?.processed(with: dataProvider,
locale: locale),
offerDetails: configuration.offerDetails.processed(with: dataProvider, locale: locale),
offerDetailsWithIntroOffer: configuration.offerDetailsWithIntroOffer?.processed(with: dataProvider,
locale: locale)
)
}

Expand Down
37 changes: 21 additions & 16 deletions RevenueCatUI/Data/Variables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import RevenueCat
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension PaywallData.LocalizedConfiguration {

func processVariables(with package: Package) -> ProcessedLocalizedConfiguration {
return .init(self, package)
func processVariables(with package: Package, locale: Locale = .current) -> ProcessedLocalizedConfiguration {
return .init(self, package, locale)
}

}
Expand All @@ -17,7 +17,7 @@ protocol VariableDataProvider {
var localizedPrice: String { get }
var localizedPricePerMonth: String { get }
var productName: String { get }
var introductoryOfferDuration: String? { get }
func introductoryOfferDuration(_ locale: Locale) -> String?

}

Expand All @@ -27,13 +27,14 @@ enum VariableHandler {

static func processVariables(
in string: String,
with provider: VariableDataProvider
with provider: VariableDataProvider,
locale: Locale = .current
) -> String {
let matches = Self.extractVariables(from: string)
var replacedString = string

for variableMatch in matches.reversed() {
let replacementValue = provider.value(for: variableMatch.variable)
let replacementValue = provider.value(for: variableMatch.variable, locale: locale)
replacedString = replacedString.replacingCharacters(in: variableMatch.range, with: replacementValue)
}

Expand Down Expand Up @@ -69,29 +70,33 @@ enum VariableHandler {
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
extension String {

func processed(with provider: VariableDataProvider) -> Self {
return VariableHandler.processVariables(in: self, with: provider)
func processed(with provider: VariableDataProvider, locale: Locale) -> Self {
return VariableHandler.processVariables(in: self, with: provider, locale: locale)
}

}

@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension VariableDataProvider {

func value(for variableName: String) -> String {
func value(for variableName: String, locale: Locale) -> String {
switch variableName {
case "price": return self.localizedPrice
case "price_per_month": return self.localizedPricePerMonth
case "product_name": return self.productName
case "intro_duration":
guard let introDuration = self.introductoryOfferDuration else {
Logger.warning(
"Unexpectedly tried to look for intro duration when there is none, this is a logic error."
)
return ""
case "total_price_and_per_month":
let price = self.localizedPrice
let perMonth = self.localizedPricePerMonth

if price == perMonth {
return price
} else {
let unit = Localization.abbreviatedUnitLocalizedString(for: .month, locale: locale)
return "\(price) (\(perMonth)/\(unit))"
}

return introDuration
case "product_name": return self.productName
case "intro_duration":
return self.introductoryOfferDuration(locale) ?? ""

default:
Logger.warning("Couldn't find content for variable '\(variableName)'")
Expand Down
10 changes: 6 additions & 4 deletions RevenueCatUI/Helpers/Package+VariableDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ extension Package: VariableDataProvider {
return self.storeProduct.localizedTitle
}

var introductoryOfferDuration: String? {
return self.introDuration
func introductoryOfferDuration(_ locale: Locale) -> String? {
self.introDuration(locale)
}

}
Expand All @@ -41,8 +41,10 @@ private extension Package {
return self.storeProduct.priceFormatter ?? .init()
}

var introDuration: String? {
return self.storeProduct.introductoryDiscount?.localizedDuration
func introDuration(_ locale: Locale) -> String? {
guard let discount = self.storeProduct.introductoryDiscount else { return nil }

return Localization.localizedDuration(for: discount.subscriptionPeriod, locale: locale)
}

}
67 changes: 0 additions & 67 deletions RevenueCatUI/Helpers/StoreProductDiscount+Localization.swift

This file was deleted.

34 changes: 32 additions & 2 deletions Tests/RevenueCatUITests/Data/VariablesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,32 @@ class VariablesTests: TestCase {
expect(self.process("{{ price_per_month }} per month")) == "$3.99 per month"
}

func testTotalPriceAndPerMonthWithDifferentPrices() {
self.provider.localizedPrice = "$49.99"
self.provider.localizedPricePerMonth = "$4.16"
expect(self.process("{{ total_price_and_per_month }}")) == "$49.99 ($4.16/mo)"
}

func testTotalPriceAndPerMonthWithDifferentPricesSpanish() {
self.provider.localizedPrice = "49,99€"
self.provider.localizedPricePerMonth = "4,16€"
expect(self.process("{{ total_price_and_per_month }}",
locale: .init(identifier: "es_ES"))) == "49,99€ (4,16€/mes)"
}

func testTotalPriceAndPerMonthWithDifferentPricesFrench() {
self.provider.localizedPrice = "49,99€"
self.provider.localizedPricePerMonth = "4,16€"
expect(self.process("{{ total_price_and_per_month }}",
locale: .init(identifier: "fr_FR"))) == "49,99€ (4,16€/m)"
}

func testTotalPriceAndPerMonthWithSamePrice() {
self.provider.localizedPrice = "$4.99"
self.provider.localizedPricePerMonth = "$4.99"
expect(self.process("{{ total_price_and_per_month }}")) == "$4.99"
}

func testProductName() {
self.provider.productName = "MindSnacks"
expect(self.process("Purchase {{ product_name }}")) == "Purchase MindSnacks"
Expand Down Expand Up @@ -84,8 +110,8 @@ class VariablesTests: TestCase {
@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)
private extension VariablesTests {

func process(_ string: String) -> String {
return VariableHandler.processVariables(in: string, with: self.provider)
func process(_ string: String, locale: Locale = .current) -> String {
return VariableHandler.processVariables(in: string, with: self.provider, locale: locale)
}

}
Expand All @@ -97,4 +123,8 @@ private struct MockVariableProvider: VariableDataProvider {
var productName: String = ""
var introductoryOfferDuration: String?

func introductoryOfferDuration(_ locale: Locale) -> String? {
return self.introductoryOfferDuration
}

}
Loading

0 comments on commit 7f82935

Please sign in to comment.