|
|
@@ -46,35 +46,33 @@ private struct SpeechLocaleOption {
|
|
|
}
|
|
|
|
|
|
private enum PremiumPlan: Int {
|
|
|
- case weekly = 0
|
|
|
- case monthly = 1
|
|
|
+ case monthly = 0
|
|
|
+ case biYearly = 1
|
|
|
case yearly = 2
|
|
|
- case lifetime = 3
|
|
|
}
|
|
|
|
|
|
private enum PremiumStoreProduct {
|
|
|
- static let weekly = "com.mqldev.meetingsapp.premium.weekly"
|
|
|
- static let monthly = "com.mqldev.meetingsapp.premium.monthly"
|
|
|
- static let yearly = "com.mqldev.meetingsapp.premium.yearly"
|
|
|
- static let lifetime = "com.mqldev.meetingsapp.premium.lifetime"
|
|
|
+ // These IDs must match App Store Connect products.
|
|
|
+ // In local StoreKit testing, keep them aligned with Products.storekit.
|
|
|
+ static let monthly = "com.hwaccount.googlemeet.1month"
|
|
|
+ static let biYearly = "com.hwaccount.googlemeet.BiYearly"
|
|
|
+ static let yearly = "com.hwaccount.googlemeet.1year"
|
|
|
|
|
|
- static let allIDs = [weekly, monthly, yearly, lifetime]
|
|
|
+ static let allIDs = [monthly, biYearly, yearly]
|
|
|
|
|
|
static func productID(for plan: PremiumPlan) -> String {
|
|
|
switch plan {
|
|
|
- case .weekly: return weekly
|
|
|
case .monthly: return monthly
|
|
|
+ case .biYearly: return biYearly
|
|
|
case .yearly: return yearly
|
|
|
- case .lifetime: return lifetime
|
|
|
}
|
|
|
}
|
|
|
|
|
|
static func plan(for productID: String) -> PremiumPlan? {
|
|
|
switch productID {
|
|
|
- case weekly: return .weekly
|
|
|
case monthly: return .monthly
|
|
|
+ case biYearly: return .biYearly
|
|
|
case yearly: return .yearly
|
|
|
- case lifetime: return .lifetime
|
|
|
default: return nil
|
|
|
}
|
|
|
}
|
|
|
@@ -97,11 +95,9 @@ private final class StoreKitCoordinator {
|
|
|
var onEntitlementsChanged: ((Bool) -> Void)?
|
|
|
|
|
|
var hasPremiumAccess: Bool { !activeEntitlementProductIDs.isEmpty }
|
|
|
- var hasLifetimeAccess: Bool { activeEntitlementProductIDs.contains(PremiumStoreProduct.lifetime) }
|
|
|
- var activeNonLifetimePlan: PremiumPlan? {
|
|
|
+ var activePlan: PremiumPlan? {
|
|
|
activeEntitlementProductIDs
|
|
|
.compactMap { PremiumStoreProduct.plan(for: $0) }
|
|
|
- .filter { $0 != .lifetime }
|
|
|
.max(by: { $0.rawValue < $1.rawValue })
|
|
|
}
|
|
|
|
|
|
@@ -365,7 +361,7 @@ final class ViewController: NSViewController {
|
|
|
private weak var paywallOverlayView: NSView?
|
|
|
private let paywallContentWidth: CGFloat = 520
|
|
|
private let launchWindowLeftOffset: CGFloat = 80
|
|
|
- private var selectedPremiumPlan: PremiumPlan = .monthly
|
|
|
+ private var selectedPremiumPlan: PremiumPlan = .yearly
|
|
|
private var paywallPlanViews: [PremiumPlan: NSView] = [:]
|
|
|
private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
|
|
|
private var paywallFooterActionByView = [ObjectIdentifier: PaywallFooterAction]()
|
|
|
@@ -562,7 +558,7 @@ final class ViewController: NSViewController {
|
|
|
let popover = NSPopover()
|
|
|
popover.behavior = .transient
|
|
|
popover.animates = true
|
|
|
- let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
|
|
|
+ let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess
|
|
|
let showRateUsInSettings = shouldShowRateUsInSettings
|
|
|
let speechOptions = aiCompanionSupportedSpeechLocaleOptions()
|
|
|
popover.contentViewController = SettingsMenuViewController(
|
|
|
@@ -834,10 +830,8 @@ private extension ViewController {
|
|
|
}
|
|
|
|
|
|
@objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
|
|
|
- if storeKitCoordinator.hasLifetimeAccess {
|
|
|
- openManageSubscriptions()
|
|
|
- } else if storeKitCoordinator.hasPremiumAccess {
|
|
|
- showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
+ showPaywall(upgradeFlow: true, preferredPlan: .yearly)
|
|
|
} else {
|
|
|
showPaywall()
|
|
|
}
|
|
|
@@ -1378,7 +1372,7 @@ private extension ViewController {
|
|
|
case .shareApp:
|
|
|
shareAppFromSettingsMenu(sourceView: sourceView, clickLocationInSourceView: clickLocationInSourceView)
|
|
|
case .upgrade:
|
|
|
- showPaywall(upgradeFlow: true, preferredPlan: .lifetime)
|
|
|
+ showPaywall(upgradeFlow: true, preferredPlan: .yearly)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2386,15 +2380,9 @@ private extension ViewController {
|
|
|
|
|
|
private func paywallOfferText(for plan: PremiumPlan) -> String {
|
|
|
if storeKitCoordinator.hasPremiumAccess {
|
|
|
- if storeKitCoordinator.hasLifetimeAccess {
|
|
|
- return "Lifetime premium is active on this Apple ID."
|
|
|
- }
|
|
|
if paywallUpgradeFlowEnabled {
|
|
|
- let currentPlanName = storeKitCoordinator.activeNonLifetimePlan?.displayName ?? "Premium"
|
|
|
- if plan == .lifetime {
|
|
|
- return "Current plan: \(currentPlanName). Tap Continue to upgrade to Lifetime."
|
|
|
- }
|
|
|
- return "Current plan: \(currentPlanName). Select Lifetime to upgrade."
|
|
|
+ let currentPlanName = storeKitCoordinator.activePlan?.displayName ?? "Premium"
|
|
|
+ return "Current plan: \(currentPlanName). Choose a different package to switch."
|
|
|
}
|
|
|
return "Premium is active on this Apple ID."
|
|
|
}
|
|
|
@@ -2416,14 +2404,12 @@ private extension ViewController {
|
|
|
}
|
|
|
let fallbackPrice = fallbackPriceText(for: plan)
|
|
|
switch plan {
|
|
|
- case .weekly:
|
|
|
- return "\(fallbackPrice)/week"
|
|
|
case .monthly:
|
|
|
return "\(fallbackPrice)/month"
|
|
|
+ case .biYearly:
|
|
|
+ return "\(fallbackPrice)/6 months"
|
|
|
case .yearly:
|
|
|
return "\(fallbackPrice)/year (3 days free trial)"
|
|
|
- case .lifetime:
|
|
|
- return "\(fallbackPrice) one-time purchase"
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2434,45 +2420,37 @@ private extension ViewController {
|
|
|
private func fallbackPriceText(for plan: PremiumPlan) -> String {
|
|
|
let fallbackAmount: Decimal
|
|
|
switch plan {
|
|
|
- case .weekly:
|
|
|
- fallbackAmount = 1100
|
|
|
case .monthly:
|
|
|
fallbackAmount = 2500
|
|
|
+ case .biYearly:
|
|
|
+ fallbackAmount = 5900
|
|
|
case .yearly:
|
|
|
fallbackAmount = 9900
|
|
|
- case .lifetime:
|
|
|
- fallbackAmount = 14900
|
|
|
}
|
|
|
return localeCurrencyText(for: fallbackAmount)
|
|
|
}
|
|
|
|
|
|
- private func fallbackLifetimeStrikePriceText() -> String {
|
|
|
- localeCurrencyText(for: 29_800)
|
|
|
- }
|
|
|
-
|
|
|
private func fallbackAmount(for plan: PremiumPlan) -> Decimal {
|
|
|
switch plan {
|
|
|
- case .weekly:
|
|
|
- return 1100
|
|
|
case .monthly:
|
|
|
return 2500
|
|
|
+ case .biYearly:
|
|
|
+ return 5900
|
|
|
case .yearly:
|
|
|
return 9900
|
|
|
- case .lifetime:
|
|
|
- return 14900
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func yearlySavingsBadgeText() -> String {
|
|
|
- let yearlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.yearly]
|
|
|
+ private func savingsPercentText(for targetPlan: PremiumPlan, monthMultiplier: Decimal) -> String {
|
|
|
+ let targetProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.productID(for: targetPlan)]
|
|
|
let monthlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.monthly]
|
|
|
|
|
|
- let yearlyAmount = yearlyProduct?.price ?? fallbackAmount(for: .yearly)
|
|
|
+ let targetAmount = targetProduct?.price ?? fallbackAmount(for: targetPlan)
|
|
|
let monthlyAmount = monthlyProduct?.price ?? fallbackAmount(for: .monthly)
|
|
|
- let yearlyCostIfMonthly = monthlyAmount * Decimal(12)
|
|
|
- guard yearlyCostIfMonthly > 0 else { return "Save 0%" }
|
|
|
+ let projectedMonthlyCost = monthlyAmount * monthMultiplier
|
|
|
+ guard projectedMonthlyCost > 0 else { return "Save 0%" }
|
|
|
|
|
|
- let savingsRatio = (yearlyCostIfMonthly - yearlyAmount) / yearlyCostIfMonthly
|
|
|
+ let savingsRatio = (projectedMonthlyCost - targetAmount) / projectedMonthlyCost
|
|
|
guard savingsRatio > 0 else { return "Save 0%" }
|
|
|
let savingsPercentDecimal = savingsRatio * Decimal(100)
|
|
|
let roundedPercent = Int(NSDecimalNumber(decimal: savingsPercentDecimal).doubleValue.rounded())
|
|
|
@@ -2480,6 +2458,14 @@ private extension ViewController {
|
|
|
return "Save \(savingsPercent)%"
|
|
|
}
|
|
|
|
|
|
+ private func biYearlySavingsBadgeText() -> String {
|
|
|
+ savingsPercentText(for: .biYearly, monthMultiplier: 6)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func yearlySavingsBadgeText() -> String {
|
|
|
+ savingsPercentText(for: .yearly, monthMultiplier: 12)
|
|
|
+ }
|
|
|
+
|
|
|
private func localeCurrencyText(for amount: Decimal) -> String {
|
|
|
let formatter = NumberFormatter()
|
|
|
formatter.numberStyle = .currency
|
|
|
@@ -2558,17 +2544,8 @@ private extension ViewController {
|
|
|
label.stringValue = recurringText
|
|
|
}
|
|
|
}
|
|
|
- for (plan, label) in paywallStrikePriceLabels {
|
|
|
- guard plan == .lifetime else { continue }
|
|
|
- let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
- if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
- label.stringValue = strikePriceText(for: product)
|
|
|
- } else {
|
|
|
- label.stringValue = fallbackLifetimeStrikePriceText()
|
|
|
- }
|
|
|
- }
|
|
|
- paywallBadgeLabels[.yearly]?.stringValue = yearlySavingsBadgeText()
|
|
|
- paywallBadgeLabels[.lifetime]?.stringValue = "Best Value"
|
|
|
+ paywallBadgeLabels[.biYearly]?.stringValue = biYearlySavingsBadgeText()
|
|
|
+ paywallBadgeLabels[.yearly]?.stringValue = "\(yearlySavingsBadgeText()) • Most Popular"
|
|
|
refreshSidebarPremiumButton()
|
|
|
refreshInstantMeetPremiumState()
|
|
|
updatePaywallPlanSelection()
|
|
|
@@ -2807,7 +2784,6 @@ private extension ViewController {
|
|
|
}
|
|
|
if paywallUpgradeFlowEnabled,
|
|
|
storeKitCoordinator.hasPremiumAccess,
|
|
|
- selectedPremiumPlan != .lifetime,
|
|
|
!confirmPremiumUpgrade(for: selectedPremiumPlan) {
|
|
|
return
|
|
|
}
|
|
|
@@ -2825,16 +2801,7 @@ private extension ViewController {
|
|
|
Task { [weak self] in
|
|
|
await self?.loadSchedule()
|
|
|
}
|
|
|
- if selectedPlan == .lifetime, self.storeKitCoordinator.activeNonLifetimePlan != nil {
|
|
|
- self.showSimpleAlert(
|
|
|
- title: "Lifetime Premium Activated",
|
|
|
- message: "You are premium for lifetime now. Please cancel your previous subscription in App Store Subscriptions to avoid future renewal charges."
|
|
|
- )
|
|
|
- } else if selectedPlan == .lifetime {
|
|
|
- self.showSimpleAlert(title: "Purchase Complete", message: "Lifetime premium has been unlocked successfully.")
|
|
|
- } else {
|
|
|
- self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
- }
|
|
|
+ self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
|
|
|
self.dismissPaywallIfPresented()
|
|
|
self.scheduleRatingPromptAfterPremiumUpgrade()
|
|
|
case .cancelled:
|
|
|
@@ -2858,7 +2825,7 @@ private extension ViewController {
|
|
|
paywallContinueButton?.alphaValue = 0.75
|
|
|
return
|
|
|
}
|
|
|
- if storeKitCoordinator.hasLifetimeAccess {
|
|
|
+ if storeKitCoordinator.hasPremiumAccess && !paywallUpgradeFlowEnabled {
|
|
|
paywallContinueEnabled = false
|
|
|
paywallContinueLabel?.stringValue = "Premium Active"
|
|
|
paywallContinueButton?.alphaValue = 0.75
|
|
|
@@ -2868,7 +2835,7 @@ private extension ViewController {
|
|
|
paywallContinueButton?.alphaValue = 1.0
|
|
|
} else {
|
|
|
paywallContinueEnabled = true
|
|
|
- paywallContinueLabel?.stringValue = (selectedPremiumPlan == .yearly) ? "Start with 3 day trial" : "Continue"
|
|
|
+ paywallContinueLabel?.stringValue = (selectedPremiumPlan == .yearly) ? "Continue with 3 day trail" : "Continue"
|
|
|
paywallContinueButton?.alphaValue = 1.0
|
|
|
}
|
|
|
}
|
|
|
@@ -4639,7 +4606,7 @@ private extension ViewController {
|
|
|
let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
|
|
|
stack.addArrangedSubview(shareButton)
|
|
|
shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
- if storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess {
|
|
|
+ if storeKitCoordinator.hasPremiumAccess {
|
|
|
let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
|
|
|
stack.addArrangedSubview(upgradeButton)
|
|
|
upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
|
@@ -5940,46 +5907,36 @@ private extension ViewController {
|
|
|
plansRow.distribution = .fillEqually
|
|
|
plansRow.alignment = .centerY
|
|
|
|
|
|
- let weeklyCard = paywallPlanCard(
|
|
|
- title: "Weekly",
|
|
|
- price: fallbackPriceText(for: .weekly),
|
|
|
- badge: "Basic",
|
|
|
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: nil,
|
|
|
- plan: .weekly,
|
|
|
- strikePrice: nil
|
|
|
- )
|
|
|
let monthlyCard = paywallPlanCard(
|
|
|
title: "Monthly",
|
|
|
price: fallbackPriceText(for: .monthly),
|
|
|
badge: "Popular",
|
|
|
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
+ badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
subtitle: nil,
|
|
|
plan: .monthly,
|
|
|
strikePrice: nil
|
|
|
)
|
|
|
+ let biYearlyCard = paywallPlanCard(
|
|
|
+ title: "Bi Yearly",
|
|
|
+ price: fallbackPriceText(for: .biYearly),
|
|
|
+ badge: biYearlySavingsBadgeText(),
|
|
|
+ badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
+ subtitle: nil,
|
|
|
+ plan: .biYearly,
|
|
|
+ strikePrice: nil
|
|
|
+ )
|
|
|
let yearlyCard = paywallPlanCard(
|
|
|
title: "Yearly",
|
|
|
price: fallbackPriceText(for: .yearly),
|
|
|
- badge: yearlySavingsBadgeText(),
|
|
|
+ badge: "\(yearlySavingsBadgeText()) • Most Popular",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
subtitle: "3 days free trial",
|
|
|
plan: .yearly,
|
|
|
strikePrice: nil
|
|
|
)
|
|
|
- let lifetimeCard = paywallPlanCard(
|
|
|
- title: "Lifetime",
|
|
|
- price: fallbackPriceText(for: .lifetime),
|
|
|
- badge: "Best Value",
|
|
|
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
- subtitle: nil,
|
|
|
- plan: .lifetime,
|
|
|
- strikePrice: nil
|
|
|
- )
|
|
|
- plansRow.addArrangedSubview(weeklyCard)
|
|
|
plansRow.addArrangedSubview(monthlyCard)
|
|
|
+ plansRow.addArrangedSubview(biYearlyCard)
|
|
|
plansRow.addArrangedSubview(yearlyCard)
|
|
|
- plansRow.addArrangedSubview(lifetimeCard)
|
|
|
contentStack.addArrangedSubview(plansRow)
|
|
|
plansRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
|
|
|
updatePaywallPlanSelection()
|
|
|
@@ -7329,10 +7286,9 @@ private extension ViewController {
|
|
|
private extension PremiumPlan {
|
|
|
var displayName: String {
|
|
|
switch self {
|
|
|
- case .weekly: return "Weekly"
|
|
|
case .monthly: return "Monthly"
|
|
|
+ case .biYearly: return "Bi Yearly"
|
|
|
case .yearly: return "Yearly"
|
|
|
- case .lifetime: return "Lifetime"
|
|
|
}
|
|
|
}
|
|
|
}
|