diff --git a/Sources/ClerkKit/Domains/Environment/Environment.swift b/Sources/ClerkKit/Domains/Environment/Environment.swift index 88c109d61..342aa725b 100644 --- a/Sources/ClerkKit/Domains/Environment/Environment.swift +++ b/Sources/ClerkKit/Domains/Environment/Environment.swift @@ -10,6 +10,7 @@ extension Clerk { public var userSettings: UserSettings public var displayConfig: DisplayConfig public var organizationSettings: OrganizationSettings + public var forceUpdate: ForceUpdate public init( authConfig: AuthConfig, @@ -21,6 +22,7 @@ extension Clerk { self.userSettings = userSettings self.displayConfig = displayConfig self.organizationSettings = organizationSettings + forceUpdate = .empty } public init(from decoder: Decoder) throws { @@ -29,6 +31,7 @@ extension Clerk { userSettings = try container.decode(UserSettings.self, forKey: .userSettings) displayConfig = try container.decode(DisplayConfig.self, forKey: .displayConfig) organizationSettings = try container.decodeIfPresent(OrganizationSettings.self, forKey: .organizationSettings) ?? .default + forceUpdate = try container.decodeIfPresent(ForceUpdate.self, forKey: .forceUpdate) ?? .empty } } } diff --git a/Sources/ClerkKit/Domains/Environment/ForceUpdate.swift b/Sources/ClerkKit/Domains/Environment/ForceUpdate.swift new file mode 100644 index 000000000..09a55d1ae --- /dev/null +++ b/Sources/ClerkKit/Domains/Environment/ForceUpdate.swift @@ -0,0 +1,31 @@ +// +// ForceUpdate.swift +// + +import Foundation + +extension Clerk.Environment { + public struct ForceUpdate: Codable, Equatable, Sendable { + public var required: Bool + public var minimumAppVersion: String? + public var appStoreURL: URL? + + public init( + required: Bool = false, + minimumAppVersion: String? = nil, + appStoreURL: URL? = nil + ) { + self.required = required + self.minimumAppVersion = minimumAppVersion + self.appStoreURL = appStoreURL + } + + public static let empty = Self() + + enum CodingKeys: String, CodingKey { + case required + case minimumAppVersion + case appStoreURL = "appStoreUrl" + } + } +} diff --git a/Sources/ClerkKit/Domains/ForceUpdate/Clerk+ForceUpdate.swift b/Sources/ClerkKit/Domains/ForceUpdate/Clerk+ForceUpdate.swift new file mode 100644 index 000000000..bf62be532 --- /dev/null +++ b/Sources/ClerkKit/Domains/ForceUpdate/Clerk+ForceUpdate.swift @@ -0,0 +1,11 @@ +// +// Clerk+ForceUpdate.swift +// + +import Foundation + +extension Clerk { + public var isForceUpdateRequired: Bool { + environment?.forceUpdate.required == true + } +} diff --git a/Sources/ClerkKitUI/Components/ForceUpdate/ForceUpdateView.swift b/Sources/ClerkKitUI/Components/ForceUpdate/ForceUpdateView.swift new file mode 100644 index 000000000..236824361 --- /dev/null +++ b/Sources/ClerkKitUI/Components/ForceUpdate/ForceUpdateView.swift @@ -0,0 +1,93 @@ +// +// ForceUpdateView.swift +// + +#if os(iOS) || os(macOS) + + import ClerkKit + import SwiftUI + + public struct ForceUpdateView: View { + @Environment(Clerk.self) private var clerk + + private let title: LocalizedStringKey + private let subtitle: LocalizedStringKey + + public init( + title: LocalizedStringKey = "Update required", + subtitle: LocalizedStringKey = "A newer version of this app is required to continue." + ) { + self.title = title + self.subtitle = subtitle + } + + public var body: some View { + if let forceUpdate = clerk.environment?.forceUpdate, + forceUpdate.required, + let appStoreURL = forceUpdate.validAppStoreURL + { + ForceUpdateContentView( + appStoreURL: appStoreURL, + title: title, + subtitle: subtitle + ) + } + } + } + + private struct ForceUpdateContentView: View { + @Environment(\.clerkTheme) private var theme + @Environment(\.openURL) private var openURL + + let appStoreURL: URL + let title: LocalizedStringKey + let subtitle: LocalizedStringKey + + var body: some View { + VStack(spacing: 24) { + AppLogoView() + + VStack(spacing: 8) { + HeaderView(style: .title, text: title) + HeaderView(style: .subtitle, text: subtitle) + } + + Button { + openURL(appStoreURL) + } label: { + Label("Update app", systemImage: "arrow.down.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.primary(config: .init(emphasis: .high, size: .large))) + } + .padding() + .frame(maxWidth: 360) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(theme.colors.background) + } + } + + #Preview { + ForceUpdateContentView( + appStoreURL: URL(string: "https://apps.apple.com/app/id123456789")!, + title: "Update required", + subtitle: "A newer version of this app is required to continue." + ) + } + + extension Clerk.Environment.ForceUpdate { + fileprivate var validAppStoreURL: URL? { + guard + let appStoreURL, + let scheme = appStoreURL.scheme, + ["http", "https"].contains(scheme), + appStoreURL.host != nil + else { + return nil + } + + return appStoreURL + } + } + +#endif diff --git a/Sources/ClerkKitUI/Resources/Localizable.xcstrings b/Sources/ClerkKitUI/Resources/Localizable.xcstrings index dad74bb1b..e11a945f0 100644 --- a/Sources/ClerkKitUI/Resources/Localizable.xcstrings +++ b/Sources/ClerkKitUI/Resources/Localizable.xcstrings @@ -661,6 +661,10 @@ } } }, + "A newer version of this app is required to continue." : { + "comment" : "Subtitle for the force update view.", + "isCommentAutoGenerated" : true + }, "A text message containing a verification code will be sent to this phone number. Message and data rates may apply." : { "localizations" : { "ar" : { @@ -3487,190 +3491,190 @@ } } }, - "Change password" : { + "Change logo" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تغيير كلمة لمرور" + "value" : "تغيير الشعار" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Passwort ändern" + "value" : "Logo ändern" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Αλλαγή κωδικού πρόσβασης" + "value" : "Αλλαγή λογότυπου" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Cambiar la contraseña" + "value" : "Cambiar logotipo" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Changer le mot de passe" + "value" : "Changer le logo" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "पासवर्ड बदलें" + "value" : "लोगो बदलें" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Cambia password" + "value" : "Cambia logo" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "パスワードを変更" + "value" : "ロゴを変更" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비밀번호 변경" + "value" : "로고 변경" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Zmień hasło" + "value" : "Zmień logo" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Alterar senha" + "value" : "Alterar logotipo" } }, "ro" : { "stringUnit" : { "state" : "translated", - "value" : "Schimbă parola" + "value" : "Schimbă sigla" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Изменить пароль" + "value" : "Изменить логотип" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Parolayı değiştir" + "value" : "Logoyu değiştir" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "更改密码" + "value" : "更改徽标" } } } }, - "Change logo" : { + "Change password" : { "localizations" : { "ar" : { "stringUnit" : { "state" : "translated", - "value" : "تغيير الشعار" + "value" : "تغيير كلمة لمرور" } }, "de" : { "stringUnit" : { "state" : "translated", - "value" : "Logo ändern" + "value" : "Passwort ändern" } }, "el" : { "stringUnit" : { "state" : "translated", - "value" : "Αλλαγή λογότυπου" + "value" : "Αλλαγή κωδικού πρόσβασης" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Cambiar logotipo" + "value" : "Cambiar la contraseña" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Changer le logo" + "value" : "Changer le mot de passe" } }, "hi" : { "stringUnit" : { "state" : "translated", - "value" : "लोगो बदलें" + "value" : "पासवर्ड बदलें" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Cambia logo" + "value" : "Cambia password" } }, "ja" : { "stringUnit" : { "state" : "translated", - "value" : "ロゴを変更" + "value" : "パスワードを変更" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로고 변경" + "value" : "비밀번호 변경" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Zmień logo" + "value" : "Zmień hasło" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Alterar logotipo" + "value" : "Alterar senha" } }, "ro" : { "stringUnit" : { "state" : "translated", - "value" : "Schimbă sigla" + "value" : "Schimbă parola" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Изменить логотип" + "value" : "Изменить пароль" } }, "tr" : { "stringUnit" : { "state" : "translated", - "value" : "Logoyu değiştir" + "value" : "Parolayı değiştir" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "更改徽标" + "value" : "更改密码" } } } @@ -13775,6 +13779,100 @@ } } }, + "No email app is available on this device." : { + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا يتوفر تطبيق بريد إلكتروني على هذا الجهاز." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf diesem Gerät ist keine E-Mail-App verfügbar." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Δεν υπάρχει διαθέσιμη εφαρμογή email σε αυτή τη συσκευή." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay ninguna app de correo disponible en este dispositivo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune app e-mail n’est disponible sur cet appareil." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "इस डिवाइस पर कोई ईमेल ऐप उपलब्ध नहीं है।" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna app e-mail è disponibile su questo dispositivo." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "このデバイスではメールアプリを利用できません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에서 사용할 수 있는 이메일 앱이 없습니다." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na tym urządzeniu nie jest dostępna żadna aplikacja e-mail." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não há nenhum app de e-mail disponível neste dispositivo." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nu există nicio aplicație de e-mail disponibilă pe acest dispozitiv." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "На этом устройстве нет доступного почтового приложения." + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bu cihazda kullanılabilir bir e-posta uygulaması yok." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备上没有可用的邮件应用。" + } + } + } + }, "No invitations sent" : { "localizations" : { "ar" : { @@ -14247,100 +14345,6 @@ } } }, - "No email app is available on this device." : { - "localizations" : { - "ar" : { - "stringUnit" : { - "state" : "translated", - "value" : "لا يتوفر تطبيق بريد إلكتروني على هذا الجهاز." - } - }, - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auf diesem Gerät ist keine E-Mail-App verfügbar." - } - }, - "el" : { - "stringUnit" : { - "state" : "translated", - "value" : "Δεν υπάρχει διαθέσιμη εφαρμογή email σε αυτή τη συσκευή." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "No hay ninguna app de correo disponible en este dispositivo." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aucune app e-mail n’est disponible sur cet appareil." - } - }, - "hi" : { - "stringUnit" : { - "state" : "translated", - "value" : "इस डिवाइस पर कोई ईमेल ऐप उपलब्ध नहीं है।" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nessuna app e-mail è disponibile su questo dispositivo." - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "このデバイスではメールアプリを利用できません。" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "이 기기에서 사용할 수 있는 이메일 앱이 없습니다." - } - }, - "pl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Na tym urządzeniu nie jest dostępna żadna aplikacja e-mail." - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Não há nenhum app de e-mail disponível neste dispositivo." - } - }, - "ro" : { - "stringUnit" : { - "state" : "translated", - "value" : "Nu există nicio aplicație de e-mail disponibilă pe acest dispozitiv." - } - }, - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "На этом устройстве нет доступного почтового приложения." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bu cihazda kullanılabilir bir e-posta uygulaması yok." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此设备上没有可用的邮件应用。" - } - } - } - }, "No verified domains" : { "localizations" : { "ar" : { @@ -23488,6 +23492,10 @@ } } }, + "Update app" : { + "comment" : "A button that opens the app store to update the app.", + "isCommentAutoGenerated" : true + }, "Update password" : { "localizations" : { "ar" : { @@ -23678,6 +23686,10 @@ } } }, + "Update required" : { + "comment" : "Title of a force update alert.", + "isCommentAutoGenerated" : true + }, "Upload logo" : { "comment" : "A button that allows the user to upload a logo.", "isCommentAutoGenerated" : true, @@ -27734,4 +27746,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Tests/Core/ForceUpdateTests.swift b/Tests/Core/ForceUpdateTests.swift new file mode 100644 index 000000000..5105fb0e4 --- /dev/null +++ b/Tests/Core/ForceUpdateTests.swift @@ -0,0 +1,34 @@ +@testable import ClerkKit +import Foundation +import Testing + +@MainActor +struct ForceUpdateTests { + @Test + func isForceUpdateRequiredIsFalseWhenEnvironmentIsMissing() { + let clerk = Clerk() + + #expect(clerk.isForceUpdateRequired == false) + } + + @Test + func isForceUpdateRequiredReflectsResolvedEnvironmentValue() { + var environment = Clerk.Environment.mock + environment.forceUpdate = .init( + required: true, + minimumAppVersion: "2.0.0", + appStoreURL: URL(string: "https://apps.apple.com/app/id123456789") + ) + + let clerk = Clerk() + clerk.environment = environment + + #expect(clerk.isForceUpdateRequired) + #expect(clerk.environment?.forceUpdate.required == true) + #expect(clerk.environment?.forceUpdate.minimumAppVersion == "2.0.0") + #expect( + clerk.environment?.forceUpdate.appStoreURL?.absoluteString + == "https://apps.apple.com/app/id123456789" + ) + } +} diff --git a/Tests/Domains/Environment/ForceUpdateDecodingTests.swift b/Tests/Domains/Environment/ForceUpdateDecodingTests.swift new file mode 100644 index 000000000..d0ea4f5e0 --- /dev/null +++ b/Tests/Domains/Environment/ForceUpdateDecodingTests.swift @@ -0,0 +1,83 @@ +@testable import ClerkKit +import Foundation +import Testing + +struct ForceUpdateDecodingTests { + private let decoder = JSONDecoder.clerkDecoder + private let encoder = JSONEncoder.clerkEncoder + + private enum TestError: Error { + case invalidUTF8 + } + + @Test + func forceUpdateDefaultsToEmptyWhenMissing() throws { + let data = try environmentJSON() + + let environment = try decoder.decode(Clerk.Environment.self, from: data) + + #expect(environment.forceUpdate == .empty) + } + + @Test + func forceUpdateDecodesResolvedRequirement() throws { + let data = try environmentJSON( + forceUpdate: """ + "force_update": { + "required": true, + "minimum_app_version": "1.2.3", + "app_store_url": "https://apps.apple.com/app/id123456789" + } + """ + ) + + let environment = try decoder.decode(Clerk.Environment.self, from: data) + + #expect(environment.forceUpdate.required) + #expect(environment.forceUpdate.minimumAppVersion == "1.2.3") + #expect( + environment.forceUpdate.appStoreURL?.absoluteString + == "https://apps.apple.com/app/id123456789" + ) + } + + @Test + func forceUpdateDecodesNotRequiredResponse() throws { + let data = try environmentJSON( + forceUpdate: """ + "force_update": { + "required": false + } + """ + ) + + let environment = try decoder.decode(Clerk.Environment.self, from: data) + + #expect(environment.forceUpdate == .empty) + } + + private func environmentJSON(forceUpdate: String? = nil) throws -> Data { + let authConfig = try encoder.encode(Clerk.Environment.AuthConfig.mock) + let userSettings = try encoder.encode(Clerk.Environment.UserSettings.mock) + let displayConfig = try encoder.encode(Clerk.Environment.DisplayConfig.mock) + guard + let authConfigString = String(data: authConfig, encoding: .utf8), + let userSettingsString = String(data: userSettings, encoding: .utf8), + let displayConfigString = String(data: displayConfig, encoding: .utf8) + else { + throw TestError.invalidUTF8 + } + + var fields = [ + "\"auth_config\": \(authConfigString)", + "\"user_settings\": \(userSettingsString)", + "\"display_config\": \(displayConfigString)", + ] + + if let forceUpdate { + fields.append(forceUpdate) + } + + return Data("{ \(fields.joined(separator: ", ")) }".utf8) + } +}