Files
purchases-ios/RevenueCatUI/Data/Localization.swift
T
Facundo Menzella 6b5c430082 chore: Enable force_unwrapping for SwiftLint (#4820)
* enable force_unwrapping

* avoid force unwrap

* fixed all errors

* remove extra disable
2025-02-26 12:40:49 +01:00

302 lines
9.4 KiB
Swift

//
// 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
//
// 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 period: SubscriptionPeriod,
locale: Locale = .current
) -> String {
let options = self.unitLocalizedString(
for: period,
styles: Self.preferedAbbreviationStyles(for: locale),
locale: locale
)
// Return the first option that matches the preferred length
return self.unitAbbreviationLengthPriorities
.lazy
.compactMap { length in options.first { $0.count == length } }
.first { _ in true } // See https://github.com/apple/swift/issues/55374
// swiftlint:disable:next force_unwrapping
?? options.last!
}
/// - Returns: an appropriate unabbreviated description for the given `unit`.
static func unitLocalizedString(
for period: SubscriptionPeriod,
locale: Locale = .current
) -> String {
return self.unitLocalizedString(
for: period,
styles: Self.preferedFullStyles(for: locale),
locale: locale
// swiftlint:disable:next force_unwrapping
).first!
}
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) ?? ""
}
static func localized(
packageType: PackageType,
locale: Locale,
fallbackLocale: Locale = Self.fallbackLocale
) -> String? {
guard let key = packageType.localizationKey else { return nil }
func value(locale: Locale, fallback: String?) -> String {
Self
.localizedBundle(locale)
.localizedString(forKey: key,
value: fallback,
table: nil)
}
// Returns the localized string
return value(
locale: locale,
// Or falls back to first localization, and if unavailable, english
fallback: value(locale: fallbackLocale, fallback: nil)
)
}
/// - Returns: the `Bundle` associated with the given locale if found
/// Defaults to `Bundle.module`.
///
/// `SwiftUI.Text` uses `EnvironmentValues.locale` and therefore
/// can be mocked in tests.
/// However, for views that load strings, this allows specifying a custom `Locale`.
/// Example:
/// ```swift
/// let text = Localization
/// .localizedBundle(locale)
/// .localizedString(
/// forKey: "string",
/// value: nil,
/// table: nil
/// )
/// ```
static func localizedBundle(_ locale: Locale) -> Bundle {
let containerBundle: Bundle = .module
let preferredLocale = Bundle.preferredLocalizations(
from: containerBundle.localizations,
forPreferences: [locale.identifier]
).first
let path = preferredLocale.flatMap { containerBundle.path(forResource: $0, ofType: "lproj") }
return path.flatMap(Bundle.init(path:)) ?? containerBundle
}
/// - Returns: localized string for a discount. Example: "37% off"
static func localized(
discount: Double,
locale: Locale
) -> String {
assert(discount >= 0, "Invalid discount: \(discount)")
let number = Int((discount * 100).rounded(.toNearestOrAwayFromZero))
let format = self.localizedBundle(locale)
.localizedString(forKey: "%d%% off", value: nil, table: nil)
return String(format: format, number)
}
}
// MARK: - Private
private extension Localization {
static func unitLocalizedString(
for period: SubscriptionPeriod,
styles: [DateComponentsFormatter.UnitsStyle],
locale: Locale = .current
) -> [String] {
var calendar: Calendar = .current
calendar.locale = locale
let date = Date()
let unit = period.unit.calendarUnit
let component = unit.component
let value = period.value
guard let sinceUnits = calendar.date(byAdding: component,
value: value,
to: date) else {
return styles.map { _ in "" }
}
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 "" }
if value == 1 {
return string
.replacingOccurrences(of: String(value), with: "")
.trimmingCharacters(in: .whitespaces)
} else {
return string
.replacingOccurrences(of: " ", with: "")
}
}
return styles.map(result(for:))
}
static func preferedAbbreviationStyles(for locale: Locale) -> [DateComponentsFormatter.UnitsStyle] {
switch locale.languageCodeIdentifier {
// Abbreviated does not fully work with Japanese
case "ja": return [.brief]
// Abbreviated is too short for German - Tag = T, Woche = Wo., Monat = M, Jahr = J
// In Brief Tag = Tg., Woche = Wo., Monat = Mo., Jahr = J the Year is just J, which is not very clear
// the full version is the best for German
case "de": return [.full]
default: return [.full, .brief, .abbreviated]
}
}
static func preferedFullStyles(for locale: Locale) -> [DateComponentsFormatter.UnitsStyle] {
// `.full` for all locales, but this allows customizing it
// if needed, like `preferedAbbreviationStyles`.
return [.full]
}
/// The order in which unit abbreviations are preferred.
static let unitAbbreviationLengthPriorities = [ 2, 3 ]
/// For falling back in case language isn't localized.
static let fallbackLocale: Locale = .init(identifier: Self.fallbackLocaleIdentifier)
private static let fallbackLocaleIdentifier: String = Locale.preferredLanguages.first ?? "en_US"
}
// 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
@unknown default:
Logger.warning("Unknown SubscriptionPeriod.Unit")
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:
Logger.warning("Unknown SubscriptionPeriod")
return .init()
}
}
}
private extension PackageType {
var localizationKey: String? {
switch self {
case .annual: return "Annual"
case .sixMonth: return "6 Month"
case .threeMonth: return "3 Month"
case .twoMonth: return "2 Month"
case .monthly: return "Monthly"
case .weekly: return "Weekly"
case .lifetime: return "Lifetime"
case .unknown, .custom:
return nil
@unknown default:
Logger.warning("Unknown localizationKey")
return nil
}
}
}
private extension Locale {
var languageCodeIdentifier: String? {
if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) {
return self.language.languageCode?.identifier
} else {
return self.languageCode
}
}
}