Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions apple/TablePro/Core/Services/Licensing/LicensePresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,40 @@ internal struct LicenseActivationFailure: Equatable {
let link: LicenseActivationLink?
}

internal struct LicenseFeatureGroup: Equatable, Identifiable {
let tier: LicenseTier
let includedTier: LicenseTier?
let features: [ProFeature]
let access: ProFeatureAccess

var id: LicenseTier { tier }
}

/// Turns license state into what the pane shows. Pure and free of any view type, so the whole grid
/// of states can be tested without building a view, the same way `LicenseManager.resolveStatus` is.
internal enum LicensePresentation {
static func featureGroups(
status: LicenseStatus,
tier: LicenseTier,
features: [ProFeature] = ProFeature.allCases
) -> [LicenseFeatureGroup] {
var tiers: [LicenseTier] = []
for feature in features where !tiers.contains(feature.requiredTier) {
tiers.append(feature.requiredTier)
}
// Lowest tier first whatever the case order, so each plan names the one it builds on.
tiers.sort { !$0.unlocks($1) }

return tiers.enumerated().map { index, groupTier in
LicenseFeatureGroup(
tier: groupTier,
includedTier: index > 0 ? tiers[index - 1] : nil,
features: features.filter { $0.requiredTier == groupTier },
access: LicenseManager.resolveAccess(status: status, tier: tier, requiredTier: groupTier)
)
}
}

/// Whether this license currently entitles anything.
///
/// The pane uses it to decide whether a renewal field belongs beside the license, not whether
Expand Down
4 changes: 2 additions & 2 deletions apple/TablePro/Core/Services/Licensing/SupportLinks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ internal enum SupportReferrer: Equatable {
}
}

/// The two places a person can pay for TablePro, and the only two outbound money links the app
/// opens.
internal enum SupportLinks {
/// `appending(queryItems:)` writes the query ahead of the fragment, which is the only order
/// that works: spelled `#pricing?ref=…` the whole tail is the fragment, so the parameter never
Expand All @@ -63,6 +61,8 @@ internal enum SupportLinks {
/// A signed-out visit signs in and comes back here, not to the overview.
internal static let accountMachines = URL(string: "https://tablepro.app/account/machines")!

internal static let licensingGuide = URL(string: "https://docs.tablepro.app/features/licensing")!

internal static func pricing(_ referrer: SupportReferrer) -> URL {
pricingPage.appending(queryItems: [URLQueryItem(name: "ref", value: referrer.parameterValue)])
}
Expand Down
2 changes: 1 addition & 1 deletion apple/TablePro/Models/Settings/LicenseTier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation

/// The subscription tier carried by a license. A higher tier unlocks everything a lower tier does.
internal enum LicenseTier: Equatable {
internal enum LicenseTier: Hashable {
case starter
case team
case unknown(String)
Expand Down
12 changes: 12 additions & 0 deletions apple/TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4530,6 +4530,9 @@
}
}
}
},
"%@ Plan" : {

},
"%@ Preview" : {
"localizations" : {
Expand Down Expand Up @@ -91555,6 +91558,12 @@
}
}
}
},
"Included" : {

},
"Includes everything in %@." : {

},
"Incorrect passphrase" : {
"extractionState" : "manual",
Expand Down Expand Up @@ -98576,6 +98585,9 @@
"Leaf" : {
"extractionState" : "manual",
"generatesSymbol" : false
},
"Learn More About Licensing" : {

},
"Lease can only be compared with a lease ID, such as 0x7b." : {
"extractionState" : "manual",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// LicenseFeaturesSections.swift
// TablePro
//

import SwiftUI

struct LicenseFeaturesSections: View {
let groups: [LicenseFeatureGroup]
let offersPurchase: Bool

var body: some View {
ForEach(groups) { group in
Section {
ForEach(group.features, id: \.self) { feature in
LicenseFeatureRow(feature: feature)
}
} header: {
header(for: group)
} footer: {
if group.id == groups.last?.id {
footer
}
}
}
}

private func header(for group: LicenseFeatureGroup) -> some View {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(String(format: String(localized: "%@ Plan"), group.tier.displayName))

if let includedTier = group.includedTier {
Text(String(format: String(localized: "Includes everything in %@."), includedTier.displayName))
.font(.callout)
.foregroundStyle(.secondary)
}
}

Spacer()

accessLabel(group.access)
}
}

@ViewBuilder
private func accessLabel(_ access: ProFeatureAccess) -> some View {
switch access {
case .available:
Label {
Text("Included")
.foregroundStyle(.secondary)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
case .requiresUpgrade:
Link("Upgrade Plan", destination: SupportLinks.pricing(.licenseSettings))
.accessibilityIdentifier("license-upgrade-plan")
case .unlicensed, .expired, .validationFailed:
EmptyView()
}
}

private var footer: some View {
HStack(spacing: 16) {
if offersPurchase {
Link("Buy a License", destination: SupportLinks.pricing(.licenseSettings))
}

Link("Learn More About Licensing", destination: SupportLinks.licensingGuide)
}
.font(.callout)
}
}

private struct LicenseFeatureRow: View {
let feature: ProFeature

var body: some View {
HStack(spacing: 10) {
Image(systemName: feature.systemImage)
.foregroundStyle(.secondary)
.frame(width: 20)
.accessibilityHidden(true)

VStack(alignment: .leading, spacing: 2) {
Text(feature.displayName)

Text(feature.featureDescription)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
}
16 changes: 9 additions & 7 deletions apple/TablePro/Views/Settings/License/LicenseSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,7 @@ struct LicenseSettingsView: View {
LicenseNoticeSection(notice: notice)
}

/// Keyed on holding a licence, not on it being in good standing. A lapsed licence
/// still has seats worth releasing and billing worth opening, and hiding them behind
/// entitlement takes the renewal route away at exactly the moment it is needed.
// Keyed on holding a licence: a lapsed one still has seats to release and a renewal to enter.
if let license = licenseManager.license {
identitySection(license)

Expand All @@ -42,9 +40,11 @@ struct LicenseSettingsView: View {
if licenseManager.showsTeamRoster {
LicenseTeamSection()
}
LicenseFeaturesSections(groups: featureGroups, offersPurchase: false)
actionsSection
} else {
landingSection
LicenseFeaturesSections(groups: featureGroups, offersPurchase: true)
}
}
.formStyle(.grouped)
Expand Down Expand Up @@ -152,16 +152,17 @@ struct LicenseSettingsView: View {
}

LicenseActivationForm()

Link("Buy a License", destination: SupportLinks.pricing(.licenseSettings))
.font(.subheadline)
}
.padding(.vertical, 4)
}
}

// MARK: - Helpers

private var featureGroups: [LicenseFeatureGroup] {
LicensePresentation.featureGroups(status: licenseManager.status, tier: licenseManager.currentTier)
}

/// A seal that stays green for a license that is not working would be the pane telling a
/// comfortable lie, so it follows entitlement.
private var sealSymbol: String {
Expand All @@ -182,7 +183,8 @@ struct LicenseSettingsView: View {
billingCycle: license.billingCycle,
expiry: license.expiresAt.map { $0.formatted(date: .abbreviated, time: .omitted) }
)
}}
}
}

/// The license key, masked, with Copy for the real one.
struct LicenseKeyRow: View {
Expand Down
83 changes: 83 additions & 0 deletions apple/TableProTests/Models/LicensePresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,87 @@ struct LicensePresentationTests {
#expect(failure.message == error.localizedDescription)
#expect(failure.link == nil)
}

// MARK: - What each plan includes

@Test("Without a license every plan is listed, lowest first, and nothing is marked included")
func noLicenseListsEveryPlan() {
let groups = LicensePresentation.featureGroups(status: .unlicensed, tier: .starter)
let tiers = groups.map { $0.tier }
let included = groups.map { $0.includedTier }
let access = groups.map { $0.access }

#expect(tiers == [.starter, .team])
#expect(included == [nil, .starter])
#expect(access == [.unlicensed, .unlicensed])
}

@Test("A Starter license includes the Starter plan and offers Team as an upgrade")
func starterLicenseOffersTheUpgrade() {
let groups = LicensePresentation.featureGroups(status: .active, tier: .starter)
let access = groups.map { $0.access }

#expect(access == [.available, .requiresUpgrade(.team)])
}

@Test("A Team license includes every plan")
func teamLicenseIncludesEveryPlan() {
let groups = LicensePresentation.featureGroups(status: .active, tier: .team)
let access = groups.map { $0.access }

#expect(access == [.available, .available])
}

@Test("A tier this build does not know yet includes every plan")
func unknownTierIncludesEveryPlan() {
let groups = LicensePresentation.featureGroups(status: .active, tier: .unknown("enterprise"))
let access = groups.map { $0.access }

#expect(access == [.available, .available])
}

@Test("A license that is not working marks nothing included and offers no upgrade")
func lapsedLicenseMarksNothing() {
let expected: [(LicenseStatus, ProFeatureAccess)] = [
(.expired, .expired),
(.validationFailed, .validationFailed),
(.suspended, .unlicensed),
(.deactivated, .unlicensed),
]

for (status, access) in expected {
let groups = LicensePresentation.featureGroups(status: status, tier: .starter)
let resolved = groups.map { $0.access }
#expect(resolved == [access, access], "\(status.rawValue)")
}
}

@Test("Every paid feature is listed once, under the plan it requires")
func everyFeatureListedOnceUnderItsPlan() {
let groups = LicensePresentation.featureGroups(status: .unlicensed, tier: .starter)
let listed = groups.flatMap { $0.features }

#expect(listed.count == ProFeature.allCases.count)
#expect(Set(listed) == Set(ProFeature.allCases))
for group in groups {
let misplaced = group.features.filter { $0.requiredTier != group.tier }
#expect(misplaced.isEmpty, "\(misplaced) listed under \(group.tier.displayName)")
}
}

@Test("Plans follow tier order, not the order features are declared in")
func plansFollowTierOrder() {
let groups = LicensePresentation.featureGroups(
status: .unlicensed,
tier: .starter,
features: [.teamLibrary, .iCloudSync, .teamCatalog]
)
let tiers = groups.map { $0.tier }
let included = groups.map { $0.includedTier }
let teamFeatures = groups.last?.features

#expect(tiers == [.starter, .team])
#expect(included == [nil, .starter])
#expect(teamFeatures == [.teamLibrary, .teamCatalog])
}
}
1 change: 1 addition & 0 deletions apple/changelog.d/3709.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Settings > License lists every paid feature by plan and marks the plans your license includes.
Loading