diff --git a/apple/TablePro/Core/Services/Licensing/LicensePresentation.swift b/apple/TablePro/Core/Services/Licensing/LicensePresentation.swift index 89083cb3a4..b51f394ee0 100644 --- a/apple/TablePro/Core/Services/Licensing/LicensePresentation.swift +++ b/apple/TablePro/Core/Services/Licensing/LicensePresentation.swift @@ -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 diff --git a/apple/TablePro/Core/Services/Licensing/SupportLinks.swift b/apple/TablePro/Core/Services/Licensing/SupportLinks.swift index 9c74282549..047650c1fb 100644 --- a/apple/TablePro/Core/Services/Licensing/SupportLinks.swift +++ b/apple/TablePro/Core/Services/Licensing/SupportLinks.swift @@ -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 @@ -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)]) } diff --git a/apple/TablePro/Models/Settings/LicenseTier.swift b/apple/TablePro/Models/Settings/LicenseTier.swift index b0174db06e..d42a2d6b24 100644 --- a/apple/TablePro/Models/Settings/LicenseTier.swift +++ b/apple/TablePro/Models/Settings/LicenseTier.swift @@ -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) diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index b34b1dc845..d851c7862b 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -4530,6 +4530,9 @@ } } } + }, + "%@ Plan" : { + }, "%@ Preview" : { "localizations" : { @@ -91555,6 +91558,12 @@ } } } + }, + "Included" : { + + }, + "Includes everything in %@." : { + }, "Incorrect passphrase" : { "extractionState" : "manual", @@ -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", diff --git a/apple/TablePro/Views/Settings/License/LicenseFeaturesSections.swift b/apple/TablePro/Views/Settings/License/LicenseFeaturesSections.swift new file mode 100644 index 0000000000..b3152f1d3d --- /dev/null +++ b/apple/TablePro/Views/Settings/License/LicenseFeaturesSections.swift @@ -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) + } +} diff --git a/apple/TablePro/Views/Settings/License/LicenseSettingsView.swift b/apple/TablePro/Views/Settings/License/LicenseSettingsView.swift index 0d92969b89..40b5c212ad 100644 --- a/apple/TablePro/Views/Settings/License/LicenseSettingsView.swift +++ b/apple/TablePro/Views/Settings/License/LicenseSettingsView.swift @@ -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) @@ -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) @@ -152,9 +152,6 @@ struct LicenseSettingsView: View { } LicenseActivationForm() - - Link("Buy a License", destination: SupportLinks.pricing(.licenseSettings)) - .font(.subheadline) } .padding(.vertical, 4) } @@ -162,6 +159,10 @@ struct LicenseSettingsView: View { // 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 { @@ -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 { diff --git a/apple/TableProTests/Models/LicensePresentationTests.swift b/apple/TableProTests/Models/LicensePresentationTests.swift index 90080d0795..7363d209e5 100644 --- a/apple/TableProTests/Models/LicensePresentationTests.swift +++ b/apple/TableProTests/Models/LicensePresentationTests.swift @@ -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]) + } } diff --git a/apple/changelog.d/3709.added.md b/apple/changelog.d/3709.added.md new file mode 100644 index 0000000000..9ddbfe451d --- /dev/null +++ b/apple/changelog.d/3709.added.md @@ -0,0 +1 @@ +Settings > License lists every paid feature by plan and marks the plans your license includes.