ET-5956: Added unlimited upsell design in UpsellScreen

This commit is contained in:
Robin Dorpe
2026-04-15 12:39:57 +02:00
parent 2e397419ae
commit 7a523ad0cd
21 changed files with 330 additions and 37 deletions
@@ -100,6 +100,8 @@ public extension DS.Icon {
static let icCode = ImageResource.icCode
static let icDiamond = ImageResource.icDiamond
static let icInfinity = ImageResource.icInfinity
static let icInfinityUpsellHeader = ImageResource.icInfinityUpsellHeader
static let icInfinityUpsellRow = ImageResource.icInfinityUpsellRow
static let icEnvelopeDot = ImageResource.icEnvelopeDot
static let icEnvelopeOpen = ImageResource.icEnvelopeOpen
static let icFileLines = ImageResource.icFileLines
@@ -41,6 +41,7 @@ public extension DS.Images {
public static let logoMobileSignature = ImageResource.upsellLogoMobileSignature
public static let logoScheduleSend = ImageResource.upsellLogoScheduleSend
public static let logoSnooze = ImageResource.upsellLogoSnooze
public static let logoUnlimited = ImageResource.upsellLogoUnlimited
public enum BlackFriday {
public static let background = ImageResource.upsellBlackFridayBackground
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Plan icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Icon.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "upsell_logo_unlimited.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -95,7 +95,7 @@ public final class UpsellCoordinator: ObservableObject {
let availablePlans = try await fetchAvailablePlans()
let model = try upsellScreenFactory.upsellScreenModel(
showingPlan: configuration.regularPlan,
showingPlan: upsellType.planVariant,
basedOn: availablePlans,
entryPoint: entryPoint,
upsellType: upsellType
+6
View File
@@ -114,6 +114,12 @@ enum L10n {
return .init("\(measurement.formatted()) storage", bundle: .module, comment: "Amount of storage space available in a given plan, for example: 1 GB storage")
}
static let hideMyEmailAliases = LocalizedStringResource("Hide My Email aliases", bundle: .module, comment: "Description of a feature of a paid subscription")
static let foldersAndLabels = LocalizedStringResource("Folders and labels", bundle: .module, comment: "Description of a feature of a paid subscription")
static let premiumVpnPasswordManagerCloudStorage = LocalizedStringResource(
"Premium VPN, password manager and cloud storage", bundle: .module, comment: "Description of a feature of a paid subscription")
static let darkWebMonitoring = LocalizedStringResource("Dark Web Monitoring", bundle: .module, comment: "Description of a feature of a paid subscription")
static func numberOfEmailAddresses(_ amount: UInt) -> LocalizedStringResource {
.init("\(amount) email addresses", bundle: .module, comment: "Number of email addresses available in a given plan")
}
@@ -2626,6 +2626,9 @@
}
}
},
"Dark Web Monitoring" : {
"comment" : "Description of a feature of a paid subscription"
},
"Deletes spam and trash after 30 days. Get this and more with %@." : {
"comment" : "Subtitle of the upsell page",
"localizations" : {
@@ -3169,6 +3172,9 @@
}
}
},
"Folders and labels" : {
"comment" : "Description of a feature of a paid subscription"
},
"Free" : {
"comment" : "Name of the free plan",
"localizations" : {
@@ -3742,6 +3748,9 @@
}
}
},
"Hide My Email aliases" : {
"comment" : "Description of a feature of a paid subscription"
},
"Make your mobile signature your own. Enjoy this and more with %@." : {
"comment" : "Subtitle of the upsell page",
"localizations" : {
@@ -5037,6 +5046,9 @@
}
}
},
"Premium VPN, password manager and cloud storage" : {
"comment" : "Description of a feature of a paid subscription"
},
"Priority customer support" : {
"comment" : "Description of a feature of a paid subscription",
"localizations" : {
@@ -16,8 +16,10 @@
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import SwiftUI
enum ComparisonItemType {
case boolean
case string(free: String, plus: String)
case integer(free: Int, plus: Int)
case string(free: String, plan: String)
case stringAndIcon(free: String, plan: Image)
}
@@ -20,22 +20,24 @@ import InboxDesignSystem
import SwiftUI
struct PlanComparisonGrid: View {
private let items: [ComparisonItem] = [
.init(title: \.storage, type: .string(free: gigabytesString(1), plus: gigabytesString(15))),
.init(title: \.emailAddresses, type: .integer(free: 1, plus: 10)),
.init(title: \.customEmailDomain, type: .boolean),
.init(title: \.accessToDesktopApp, type: .boolean),
.init(title: \.unlimitedFoldersAndLabels, type: .boolean),
.init(title: \.priorityCustomerSupport, type: .boolean),
]
struct Configuration {
enum ColumnHeader {
case text(LocalizedStringResource)
case icon(Image)
}
let planColumnHeader: ColumnHeader
let headerStroke: (any ShapeStyle)?
let items: [ComparisonItem]
}
private let configuration: Configuration
private let highlightBorderWidth: CGFloat = 2
private let highlightStroke: any ShapeStyle
@State private var highlightedColumnWidth: CGFloat = 0
init(highlightStroke: (any ShapeStyle)? = nil) {
self.highlightStroke = highlightStroke ?? LinearGradient.highlight
init(configuration: Configuration) {
self.configuration = configuration
}
var body: some View {
@@ -45,13 +47,15 @@ struct PlanComparisonGrid: View {
Text(L10n.PlanName.free)
Text(L10n.PlanName.plus)
planColumnHeaderView
.padding(.vertical, DS.Spacing.compact)
.padding(.horizontal, DS.Spacing.standard)
.overlay {
RoundedRectangle(cornerRadius: DS.Radius.medium)
.stroke(AnyShapeStyle(highlightStroke), lineWidth: highlightBorderWidth)
.padding(highlightBorderWidth / 2)
if let headerStroke = configuration.headerStroke {
RoundedRectangle(cornerRadius: DS.Radius.medium)
.stroke(AnyShapeStyle(headerStroke), lineWidth: highlightBorderWidth)
.padding(highlightBorderWidth / 2)
}
}
.padding(.horizontal, DS.Spacing.small)
.coordinatedMinWidth(using: _highlightedColumnWidth)
@@ -59,10 +63,10 @@ struct PlanComparisonGrid: View {
.font(.callout)
.fontWeight(.semibold)
ForEach(items.indices, id: \.self) { itemIndex in
gridRow(for: items[itemIndex])
ForEach(configuration.items.indices, id: \.self) { itemIndex in
gridRow(for: configuration.items[itemIndex])
if itemIndex != items.indices.last {
if itemIndex != configuration.items.indices.last {
Divider()
.overlay(.white.opacity(0.12))
}
@@ -82,6 +86,19 @@ struct PlanComparisonGrid: View {
}
}
@ViewBuilder
private var planColumnHeaderView: some View {
switch configuration.planColumnHeader {
case .text(let resource):
Text(resource)
case .icon(let image):
image
.resizable()
.scaledToFit()
.frame(height: 32)
}
}
private func gridRow(for item: ComparisonItem) -> some View {
GridRow {
Text(L10n.Perk.self[keyPath: item.title])
@@ -97,25 +114,24 @@ struct PlanComparisonGrid: View {
.symbolRenderingMode(.palette)
.foregroundStyle(.white, .black.opacity(0.2))
.coordinatedMinWidth(using: _highlightedColumnWidth)
case .integer(let valueForFreePlan, let valueForPlus):
Text("\(valueForFreePlan)")
Text("\(valueForPlus)")
.coordinatedMinWidth(using: _highlightedColumnWidth)
case .string(let valueForFreePlan, let valueForPlus):
case .string(let valueForFreePlan, let valueForPlan):
Text(valueForFreePlan)
Text(valueForPlus)
Text(valueForPlan)
.padding(.horizontal, DS.Spacing.small)
.coordinatedMinWidth(using: _highlightedColumnWidth)
case .stringAndIcon(let valueForFreePlan, let iconForPlan):
Text(valueForFreePlan)
iconForPlan
.font(.system(size: 20))
.padding(.horizontal, DS.Spacing.small)
.coordinatedMinWidth(using: _highlightedColumnWidth)
}
}
.fontWeight(.semibold)
}
}
private static func gigabytesString(_ value: Double) -> String {
Measurement<UnitInformationStorage>(value: value, unit: .gigabytes).formatted()
}
}
private extension View {
@@ -128,7 +144,16 @@ private extension View {
#Preview {
ScrollView {
PlanComparisonGrid()
PlanComparisonGrid(
configuration: .init(
planColumnHeader: .text(L10n.PlanName.plus),
headerStroke: LinearGradient.highlight,
items: [
.init(title: \.storage, type: .string(free: "1 GB", plan: "15 GB")),
.init(title: \.customEmailDomain, type: .boolean),
]
)
)
}
.background(LinearGradient.screenBackground)
.preferredColorScheme(.dark)
@@ -148,7 +148,7 @@ public struct UpsellScreen: View {
Spacer.exactly(DS.Spacing.huge)
}
PlanComparisonGrid(highlightStroke: model.highlightStroke)
PlanComparisonGrid(configuration: model.comparisonConfiguration)
}
.padding(.top, headerHeight)
.padding(.bottom, DS.Spacing.extraLarge)
@@ -27,8 +27,16 @@ extension UpsellScreenModel {
planInstances = DisplayablePlanInstance.previews
}
let planName: String
switch upsellType {
case .mailPlus:
planName = "Mail Plus"
case .unlimited:
planName = "Proton Unlimited"
}
return .init(
planName: "Mail Plus",
planName: planName,
planInstances: planInstances,
entryPoint: entryPoint,
upsellType: upsellType,
@@ -40,11 +40,17 @@ public final class UpsellScreenModel: Identifiable {
var logo: ImageResource {
switch upsellType {
case .mailPlus, .unlimited:
case .mailPlus:
entryPoint.logo
case .unlimited:
upsellType.logo
}
}
var comparisonConfiguration: PlanComparisonGrid.Configuration {
upsellType.comparisonConfiguration
}
var logoHeight: CGFloat? {
isPromo ? nil : 118
}
@@ -0,0 +1,82 @@
//
// Copyright (c) 2026 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import InboxDesignSystem
import SwiftUI
import proton_app_uniffi
extension UpsellType {
var planVariant: String {
switch self {
case .mailPlus:
SubscriptionPlanVariant.plus
case .unlimited:
SubscriptionPlanVariant.unlimited
}
}
var logo: ImageResource {
switch self {
case .mailPlus:
DS.Images.Upsell.logoDefault
case .unlimited:
DS.Images.Upsell.logoUnlimited
}
}
var comparisonConfiguration: PlanComparisonGrid.Configuration {
switch self {
case .mailPlus:
.init(
planColumnHeader: .text(L10n.PlanName.plus),
headerStroke: LinearGradient.highlight,
items: Self.mailPlusComparisonItems
)
case .unlimited:
.init(
planColumnHeader: .icon(Image(DS.Icon.icInfinityUpsellHeader)),
headerStroke: nil,
items: Self.unlimitedComparisonItems
)
}
}
private static let mailPlusComparisonItems: [ComparisonItem] = [
.init(title: \.storage, type: .string(free: gigabytesString(1), plan: gigabytesString(15))),
.init(title: \.emailAddresses, type: .string(free: "1", plan: "10")),
.init(title: \.customEmailDomain, type: .boolean),
.init(title: \.accessToDesktopApp, type: .boolean),
.init(title: \.unlimitedFoldersAndLabels, type: .boolean),
.init(title: \.priorityCustomerSupport, type: .boolean),
]
private static let unlimitedComparisonItems: [ComparisonItem] = [
.init(title: \.storage, type: .string(free: gigabytesString(1), plan: gigabytesString(500))),
.init(title: \.hideMyEmailAliases, type: .stringAndIcon(free: "10", plan: Image(DS.Icon.icInfinityUpsellRow))),
.init(title: \.foldersAndLabels, type: .stringAndIcon(free: "3", plan: Image(DS.Icon.icInfinityUpsellRow))),
.init(title: \.premiumVpnPasswordManagerCloudStorage, type: .boolean),
.init(title: \.darkWebMonitoring, type: .boolean),
.init(title: \.customEmailDomain, type: .boolean),
.init(title: \.accessToDesktopApp, type: .boolean),
.init(title: \.priorityCustomerSupport, type: .boolean),
]
private static func gigabytesString(_ value: Double) -> String {
Measurement<UnitInformationStorage>(value: value, unit: .gigabytes).formatted()
}
}
@@ -69,4 +69,16 @@ final class UpsellCoordinatorTests {
_ = try await self.sut.presentUpsellScreen(entryPoint: .mailboxTopBar)
#expect(telemetryReporting.upsellButtonTappedCalls == 1)
}
@Test
func unlimitedUpsellTypeFetchesUnlimitedPlan() async throws {
let model = try await sut.presentUpsellScreen(entryPoint: .mailboxTopBar, upsellType: .unlimited)
#expect(model.planName == "Proton Unlimited")
}
@Test
func mailPlusUpsellTypeFetchesMailPlusPlan() async throws {
let model = try await sut.presentUpsellScreen(entryPoint: .mailboxTopBar, upsellType: .mailPlus)
#expect(model.planName == "Mail Plus")
}
}
@@ -57,6 +57,30 @@ final class UpsellScreenModelTests {
#expect(planPurchasing.purchaseInvocations.count == 1)
}
@Test
func mailPlusLogoComesFromEntryPoint() {
let sut = makeSUT(upsellType: .mailPlus)
#expect(sut.logo == UpsellEntryPoint.mailboxTopBar.logo)
}
@Test
func unlimitedLogoComesFromUpsellType() {
let sut = makeSUT(upsellType: .unlimited)
#expect(sut.logo == UpsellType.unlimited.logo)
}
@Test
func mailPlusComparisonConfigurationHasSixItems() {
let sut = makeSUT(upsellType: .mailPlus)
#expect(sut.comparisonConfiguration.items.count == 6)
}
@Test
func unlimitedComparisonConfigurationHasEightItems() {
let sut = makeSUT(upsellType: .unlimited)
#expect(sut.comparisonConfiguration.items.count == 8)
}
private func makeSUT(upsellType: UpsellType = .mailPlus) -> UpsellScreenModel {
.init(
planName: "foo",
@@ -0,0 +1,65 @@
//
// Copyright (c) 2026 Proton Technologies AG
//
// This file is part of Proton Mail.
//
// Proton Mail is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
import InboxDesignSystem
import Testing
import proton_app_uniffi
@testable import InboxIAP
struct UpsellTypeUpsellScreenTests {
@Test
func mailPlusPlanVariant() {
#expect(UpsellType.mailPlus.planVariant == SubscriptionPlanVariant.plus)
}
@Test
func unlimitedPlanVariant() {
#expect(UpsellType.unlimited.planVariant == SubscriptionPlanVariant.unlimited)
}
@Test
func mailPlusLogo() {
#expect(UpsellType.mailPlus.logo == DS.Images.Upsell.logoDefault)
}
@Test
func unlimitedLogo() {
#expect(UpsellType.unlimited.logo == DS.Images.Upsell.logoUnlimited)
}
@Test
func mailPlusComparisonConfigurationHasTextHeader() {
let config = UpsellType.mailPlus.comparisonConfiguration
guard case .text = config.planColumnHeader else {
Issue.record("Expected .text header for mailPlus")
return
}
#expect(config.items.count == 6)
}
@Test
func unlimitedComparisonConfigurationHasIconHeader() {
let config = UpsellType.unlimited.comparisonConfiguration
guard case .icon = config.planColumnHeader else {
Issue.record("Expected .icon header for unlimited")
return
}
#expect(config.items.count == 8)
}
}
@@ -33,7 +33,7 @@ final class UpsellScreenFactoryTests {
@Test
func upsellScreenModelGeneration() throws {
let upsellScreenModel = try sut.upsellScreenModel(
showingPlan: configuration.regularPlan,
showingPlan: SubscriptionPlanVariant.plus,
basedOn: availablePlans,
entryPoint: entryPoint,
upsellType: .mailPlus
@@ -43,6 +43,18 @@ final class UpsellScreenFactoryTests {
#expect(upsellScreenModel.planInstances == DisplayablePlanInstance.previews)
}
@Test
func unlimitedUpsellScreenModelGeneration() throws {
let upsellScreenModel = try sut.upsellScreenModel(
showingPlan: SubscriptionPlanVariant.unlimited,
basedOn: availablePlans,
entryPoint: entryPoint,
upsellType: .unlimited
)
#expect(upsellScreenModel.planName == "Proton Unlimited")
}
@Test
func onboardingUpsellScreenModelGeneration() throws {
let upsellScreenModel = try sut.onboardingUpsellScreenModel(