diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index af2fefef28..c2ecd06c55 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -200,6 +200,7 @@ 42F1DF385E3C1F9903A07FBF /* ProductsFetcherSK1.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFB3CBAA73855779FE828CE2 /* ProductsFetcherSK1.swift */; }; 4F0201C42A13C85500091612 /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0201C32A13C85500091612 /* Assertions.swift */; }; 4F05876F2A5DE03F00E9A834 /* PaywallDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */; }; + 4F062D322A85A11600A8A613 /* PaywallData+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */; }; 4F0BBA812A1D0524000E75AB /* DefaultDecodable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */; }; 4F0BBAAC2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */; }; 4F0CE2BD2A215CE600561895 /* TransactionPosterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */; }; @@ -968,6 +969,7 @@ 37E35FDA0A44EA03EA12DAA2 /* DateFormatter+ExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DateFormatter+ExtensionsTests.swift"; sourceTree = ""; }; 4F0201C32A13C85500091612 /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallDataTests.swift; sourceTree = ""; }; + 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaywallData+Localization.swift"; sourceTree = ""; }; 4F0BBA802A1D0524000E75AB /* DefaultDecodable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultDecodable.swift; sourceTree = ""; }; 4F0BBAAB2A1D253D000E75AB /* OfflineCustomerInfoCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineCustomerInfoCreatorTests.swift; sourceTree = ""; }; 4F0CE2BC2A215CE600561895 /* TransactionPosterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionPosterTests.swift; sourceTree = ""; }; @@ -2259,6 +2261,7 @@ children = ( 4FBBD4E52A620573001CBA21 /* PaywallColor.swift */, 4F87610E2A5C9E490006FA14 /* PaywallData.swift */, + 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */, 4F87612B2A5CAB980006FA14 /* PaywallTemplate.swift */, 4F6ABC772A81595900250E63 /* PaywallCacheWarming.swift */, 4F89A55C2A6ABADF008A411E /* PaywallViewMode.swift */, @@ -3395,6 +3398,7 @@ 579415D2293689DD00218FBC /* Codable+Extensions.swift in Sources */, 2DDF41B424F6F387005BC22D /* ASN1ContainerBuilder.swift in Sources */, 57F3C10529B7B22E0004FD7E /* CustomerInfo+ActiveDates.swift in Sources */, + 4F062D322A85A11600A8A613 /* PaywallData+Localization.swift in Sources */, B35F9E0926B4BEED00095C3F /* String+Extensions.swift in Sources */, 574A2EE7282C3F0800150D40 /* AnyDecodable.swift in Sources */, 57488B7F29CB70E50000EE7E /* ProductEntitlementMapping.swift in Sources */, diff --git a/RevenueCatUI/Data/Localization.swift b/RevenueCatUI/Data/Localization.swift index 973b460a10..dd2adb58c4 100644 --- a/RevenueCatUI/Data/Localization.swift +++ b/RevenueCatUI/Data/Localization.swift @@ -153,7 +153,9 @@ private extension Localization { static let unitAbbreviationLengthPriorities = [ 2, 3 ] /// For falling back in case language isn't localized. - static let defaultLocale: Locale = .init(identifier: "en_US") + static let defaultLocale: Locale = .init(identifier: Self.defaultLocaleIdentifier) + + private static let defaultLocaleIdentifier: String = Locale.preferredLanguages.first ?? "en_US" } diff --git a/Sources/Paywalls/PaywallData+Localization.swift b/Sources/Paywalls/PaywallData+Localization.swift new file mode 100644 index 0000000000..be7626fe6a --- /dev/null +++ b/Sources/Paywalls/PaywallData+Localization.swift @@ -0,0 +1,48 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PaywallData+Localization.swift +// +// Created by Nacho Soto on 8/10/23. + +import Foundation + +public extension PaywallData { + + /// - Returns: the ``PaywallData/LocalizedConfiguration-swift.struct`` to be used + /// based on `Locale.current` or `Locale.preferredLocales`. + var localizedConfiguration: LocalizedConfiguration { + let locales: [Locale] = [.current] + Locale.preferredLocales + + return locales + .lazy + .compactMap(self.config(for:)) + .first ?? self.fallbackLocalizedConfiguration + } + + private var fallbackLocalizedConfiguration: LocalizedConfiguration { + // This can't happen because `localization` has `@EnsureNonEmptyCollectionDecodable`. + guard let result = self.localization.first?.value else { + fatalError("Corrupted data: localization is empty.") + } + + return result + } + +} + +// MARK: - + +private extension Locale { + + static var preferredLocales: [Self] { + return Self.preferredLanguages.map(Locale.init(identifier:)) + } + +} diff --git a/Sources/Paywalls/PaywallData.swift b/Sources/Paywalls/PaywallData.swift index 0ab3f16a64..389e0cc60b 100644 --- a/Sources/Paywalls/PaywallData.swift +++ b/Sources/Paywalls/PaywallData.swift @@ -31,8 +31,8 @@ public struct PaywallData { /// The base remote URL where assets for this paywall are stored. public var assetBaseURL: URL - fileprivate var defaultLocaleIdentifier: String - fileprivate var localization: [String: LocalizedConfiguration] + @EnsureNonEmptyCollectionDecodable + internal var localization: [String: LocalizedConfiguration] } @@ -130,30 +130,6 @@ extension PaywallData { }?.value } - /// The default `Locale` used if `Locale.current` is not configured for this paywall. - public var defaultLocale: Locale { - return .init(identifier: self.defaultLocaleIdentifier) - } - - /// - Returns: the ``PaywallData/LocalizedConfiguration-swift.struct`` associated to the current `Locale` - /// or the configuration associated to ``defaultLocale``. - public var localizedConfiguration: LocalizedConfiguration { - return self.config(for: Locale.current) ?? self.defaultLocalizedConfiguration - } - - private var defaultLocalizedConfiguration: LocalizedConfiguration { - let defaultLocale = self.defaultLocale - - guard let result = self.config(for: defaultLocale) else { - fatalError( - "Corrupted data. Expected to find locale \(defaultLocale.identifier) " + - "in locales: \(Set(self.localization.keys))" - ) - } - - return result - } - } extension PaywallData.LocalizedConfiguration { @@ -353,13 +329,11 @@ extension PaywallData { init( template: PaywallTemplate, config: Configuration, - defaultLocale: String, localization: [String: LocalizedConfiguration], assetBaseURL: URL ) { self.template = template self.config = config - self.defaultLocaleIdentifier = defaultLocale self.localization = localization self.assetBaseURL = assetBaseURL } @@ -376,7 +350,6 @@ extension PaywallData { self.init( template: template, config: config, - defaultLocale: locale, localization: [locale: localization], assetBaseURL: assetBaseURL ) @@ -435,7 +408,6 @@ extension PaywallData: Codable { // Note: these are camel case but converted by the decoder private enum CodingKeys: String, CodingKey { case template = "templateName" - case defaultLocaleIdentifier = "defaultLocale" case config case localization = "localizedStrings" case assetBaseURL = "assetBaseUrl" diff --git a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift index d88f12ac5c..d05f5836f6 100644 --- a/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift +++ b/Tests/APITesters/SwiftAPITester/SwiftAPITester/PaywallAPI.swift @@ -15,8 +15,7 @@ import SwiftUI func checkPaywallData(_ data: PaywallData) { let template: PaywallTemplate = data.template let config: PaywallData.Configuration = data.config - let locale: Locale = data.defaultLocale - let _: PaywallData.LocalizedConfiguration? = data.config(for: locale) + let _: PaywallData.LocalizedConfiguration? = data.config(for: Locale.current) let localization: PaywallData.LocalizedConfiguration = data.localizedConfiguration let assetBaseURL: URL = data.assetBaseURL diff --git a/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json b/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json index eb9b00e9bd..87b36f4f54 100644 --- a/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json +++ b/Tests/BackendIntegrationTests/__Snapshots__/StoreKitIntegrationTests/testCanGetOfferings.1.json @@ -56,7 +56,6 @@ "privacy_url" : "https://revenuecat.com/privacy", "tos_url" : "https://revenuecat.com/tos" }, - "default_locale" : "en_US", "localized_strings" : { "en_US" : { "call_to_action" : "Purchase for {{ sub_price_per_month }} per month", diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json index 93547ff6a3..65da9802cf 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/Offerings.json @@ -68,7 +68,6 @@ "offer_details_with_intro_offer": "Comienza tu prueba de {{ sub_offer_duration }}, y después {{ sub_price_per_month }} cada mes" } }, - "default_locale": "en_US", "config": { "packages": ["$rc_monthly", "$rc_annual", "custom_package"], "images": { diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json index 9bf2342f1c..19e61b9ac3 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-Sample1.json @@ -36,7 +36,6 @@ ] } }, - "default_locale": "en_US", "config": { "packages": ["$rc_monthly", "$rc_annual", "custom_package"], "default_package": "$rc_annual", diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json index 8d93367165..601c505638 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_locale.json @@ -10,7 +10,6 @@ "offer_details_with_intro_offer": "Comienza tu prueba de {{ sub_offer_duration }}, y después {{ sub_price_per_month }} cada mes" } }, - "default_locale": "es_ES", "config": { "packages": ["$rc_monthly", "$rc_annual"], "images": {}, diff --git a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_localization.json similarity index 51% rename from Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json rename to Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_localization.json index df5769f8f2..1c6882a6b4 100644 --- a/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_current_and_default_locale.json +++ b/Tests/UnitTests/Networking/Responses/Fixtures/PaywallData-missing_localization.json @@ -1,16 +1,6 @@ { "template_name": "1", - "localized_strings": { - "it_IT": { - "title": "Paywall", - "subtitle": "Description", - "call_to_action": "Purchase now", - "call_to_action_with_intro_offer": "Purchase now", - "offer_details": "{{ sub_price_per_month }} per month", - "offer_details_with_intro_offer": "Start your {{ sub_offer_duration }} trial, then {{ sub_price_per_month }} per month" - } - }, - "default_locale": "es_ES", + "localized_strings": {}, "config": { "packages": ["$rc_monthly", "$rc_annual"], "images": {}, diff --git a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift index 61f536d64f..70e9e82405 100644 --- a/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift +++ b/Tests/UnitTests/Networking/Responses/OfferingsDecodingTests.swift @@ -111,7 +111,6 @@ class OfferingsDecodingTests: BaseHTTPResponseTest { let paywall = try XCTUnwrap(offering.paywall) expect(paywall.template) == .template1 - expect(paywall.defaultLocale) == Locale(identifier: "en_US") try expect(paywall.assetBaseURL) == XCTUnwrap(URL(string: "https://rc-paywalls.s3.amazonaws.com")) expect(paywall.config.packages) == ["$rc_monthly", "$rc_annual", "custom_package"] diff --git a/Tests/UnitTests/Paywalls/PaywallDataTests.swift b/Tests/UnitTests/Paywalls/PaywallDataTests.swift index 68ba883afe..8ba116b712 100644 --- a/Tests/UnitTests/Paywalls/PaywallDataTests.swift +++ b/Tests/UnitTests/Paywalls/PaywallDataTests.swift @@ -17,20 +17,10 @@ import XCTest class PaywallDataTests: BaseHTTPResponseTest { - override func setUp() { - super.setUp() - - expect(Locale.current.identifier).to( - equal(Self.defaultLocale), - description: "Tests require this" - ) - } - func testSample1() throws { let paywall: PaywallData = try self.decodeFixture("PaywallData-Sample1") expect(paywall.template) == .template1 - expect(paywall.defaultLocale) == Locale(identifier: Self.defaultLocale) expect(paywall.assetBaseURL) == URL(string: "https://rc-paywalls.s3.amazonaws.com")! expect(paywall.config.packages) == ["$rc_monthly", "$rc_annual", "custom_package"] expect(paywall.config.defaultPackage) == "$rc_annual" @@ -111,11 +101,9 @@ class PaywallDataTests: BaseHTTPResponseTest { expect(paywall.config(for: Locale(identifier: "fr"))).to(beNil()) } - func testMissingCurrentLocaleLoadsDefault() throws { + func testMissingCurrentLocaleLoadsAvailableLocale() throws { let paywall: PaywallData = try self.decodeFixture("PaywallData-missing_current_locale") - expect(paywall.defaultLocale.identifier) == "es_ES" - let localization = paywall.localizedConfiguration expect(localization.callToAction) == "Comprar" expect(localization.title) == "Tienda" @@ -128,14 +116,10 @@ class PaywallDataTests: BaseHTTPResponseTest { } #if !os(watchOS) - func testMissingCurrentAndDefaultFails() throws { - let paywall: PaywallData = try self.decodeFixture("PaywallData-missing_current_and_default_locale") - - expect(paywall.defaultLocale.identifier) == "es_ES" - + func testMissingLocalizationFails() throws { expect { - let _: PaywallData.LocalizedConfiguration = paywall.localizedConfiguration - }.to(throwAssertion()) + let _: PaywallData = try self.decodeFixture("PaywallData-missing_localization") + }.to(throwError()) } #endif