diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 114 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 5 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 23 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 96 | ||||
| -rw-r--r-- | DomainDig/PaywallView.swift | 123 | ||||
| -rw-r--r-- | DomainDig/PurchaseService.swift | 245 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 25 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 3 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 53 |
10 files changed, 586 insertions, 103 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index 1a6861a..d9f3fc7 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { static var current: String { - "3.0.0" + "3.1.0" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 9802f77..e6e7a41 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -25,20 +25,21 @@ private struct WorkflowNavigationTarget: Hashable { struct ContentView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel + @State private var purchaseService = PurchaseService.shared @State private var navigationPath = NavigationPath() @FocusState private var domainFieldFocused: Bool @State private var customPortInput = "" @State private var customPortsExpanded = false @State private var trackingNoteDraft = "" @State private var editingTrackedDomain: TrackedDomain? - @State private var showTrackLimitAlert = false - @State private var featureGateMessage: String? @State private var inputMode: LookupInputMode = .single @State private var collapsedSections: Set<ResultSection> = [.network] @State private var showingCurrentDomainWorkflowSheet = false @State private var showingBatchWorkflowSheet = false var body: some View { + let _ = purchaseService.currentTier + NavigationStack(path: $navigationPath) { ScrollView(.vertical) { VStack(spacing: 0) { @@ -84,9 +85,7 @@ struct ContentView: View { workflows: viewModel.currentDomainWorkflows, trackingLimitMessage: viewModel.trackingLimitMessage, onTrack: { - if !viewModel.trackCurrentDomain() { - showTrackLimitAlert = true - } + _ = viewModel.trackCurrentDomain() }, onTogglePinned: { guard let trackedDomain = viewModel.currentTrackedDomain else { return } @@ -248,23 +247,6 @@ struct ContentView: View { navigationPath = NavigationPath() domainFieldFocused = false } - .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { - Button("OK", role: .cancel) {} - } message: { - Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.") - } - .alert("Feature unavailable", isPresented: Binding( - get: { featureGateMessage != nil }, - set: { newValue in - if !newValue { - featureGateMessage = nil - } - } - )) { - Button("OK", role: .cancel) {} - } message: { - Text(featureGateMessage ?? "") - } .sheet(item: $editingTrackedDomain) { trackedDomain in NavigationStack { Form { @@ -346,6 +328,12 @@ struct ContentView: View { .font(appDensity.font(.caption)) .foregroundStyle(.secondary) + if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() { + Text(batchAllowanceSummary) + .font(appDensity.font(.caption2)) + .foregroundStyle(.secondary) + } + TextField( "example.com\napple.com, openai.com", text: $viewModel.bulkInput, @@ -391,25 +379,25 @@ struct ContentView: View { Menu { if !viewModel.isCurrentDomainTracked { Button("Track this domain") { - if !viewModel.trackCurrentDomain() { - showTrackLimitAlert = true - } + _ = viewModel.trackCurrentDomain() } } Button("Add to workflow") { - if FeatureAccessService.hasAccess(to: .workflows) { - showingCurrentDomainWorkflowSheet = true - } else { - featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) - } + showingCurrentDomainWorkflowSheet = true } - Button("Copy report JSON") { - guard let json = viewModel.exportJSONString() else { return } - AppClipboard.copy(json) - AppHaptics.copy() + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Copy report JSON") { + guard let json = viewModel.exportJSONString() else { return } + AppClipboard.copy(json) + AppHaptics.copy() + } + } else { + Button("Copy report JSON") { + viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports) + } } Button("Export report") { - shareSingleResults(format: .json) + shareSingleResults(format: .text) } } label: { Image(systemName: "bolt.circle") @@ -463,11 +451,7 @@ struct ContentView: View { if !viewModel.currentBatchResultEntries.isEmpty { Menu { Button("Add to Workflow") { - if FeatureAccessService.hasAccess(to: .workflows) { - showingBatchWorkflowSheet = true - } else { - featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) - } + showingBatchWorkflowSheet = true } Divider() Button("Export Batch TXT") { @@ -2165,6 +2149,7 @@ private extension String { struct SettingsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel + @State private var purchaseService = PurchaseService.shared @AppStorage(DNSResolverOption.userDefaultsKey) private var storedResolverURL = DNSResolverOption.defaultURLString @AppStorage(AppDensity.userDefaultsKey) @@ -2185,6 +2170,8 @@ struct SettingsView: View { } var body: some View { + let _ = purchaseService.currentTier + Form { Section("Display") { Picker("Density", selection: $storedDensity) { @@ -2233,28 +2220,51 @@ struct SettingsView: View { } } - Section("Features") { - LabeledContent("Tier", value: FeatureAccessService.currentTier.title) + Section("Pro") { + LabeledContent("Status", value: purchaseService.currentTier.title) - if FeatureAccessService.enabledFeatureLabels().isEmpty { - Text("No features enabled.") - .font(appDensity.font(.caption, design: .default)) - .foregroundStyle(.secondary) + if purchaseService.currentTier == .free { + Button("Upgrade to Pro") { + viewModel.isPaywallPresented = true + } } else { - ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in - Text(label) + Button("Manage Subscription") { + Task { + await purchaseService.manageSubscription() + } } } - Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.") - .font(appDensity.font(.caption, design: .default)) - .foregroundStyle(.secondary) + Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { + Task { + await purchaseService.restorePurchases() + } + } + .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) + + if let statusMessage = purchaseService.statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + + if let errorMessage = purchaseService.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.red) + } + } + + Section("Features") { + ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in + Text(label) + } } Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") - LabeledContent("Report Schema", value: "3.0.0") + LabeledContent("Report Schema", value: "3.1.0") } } .navigationTitle("Settings") diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index 5eeed32..ae81654 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -11,6 +11,7 @@ import SwiftUI struct DomainDigApp: App { @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue @State private var viewModel = DomainViewModel() + @State private var purchaseService = PurchaseService.shared init() { LocalNotificationService.shared.configureForegroundPresentation() @@ -20,6 +21,10 @@ struct DomainDigApp: App { WindowGroup { RootTabView(viewModel: viewModel) .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) + .task { + let _ = purchaseService.currentTier + await purchaseService.refreshEntitlements() + } } } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 5288eaa..6ec7e64 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -226,6 +226,8 @@ final class DomainViewModel { var watchlistSearchText = "" var watchlistFilter: WatchlistFilterOption = .all var watchlistSortOption: WatchlistSortOption = .pinned + var upgradePrompt: UpgradePromptContext? + var isPaywallPresented = false var trimmedDomain: String { domain @@ -578,6 +580,7 @@ final class DomainViewModel { } guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count) return false } @@ -737,12 +740,18 @@ final class DomainViewModel { func runBulkLookup() { let domains = parsedDomains(from: bulkInput) guard !domains.isEmpty else { return } - guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return } + guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count) + return + } startBatchLookup(domains: domains, source: .manual) } func refreshAllTrackedDomains() { - guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return } + guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count) + return + } startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) } @@ -791,7 +800,10 @@ final class DomainViewModel { let normalizedDomains = normalizedDomains(domains) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } - guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil } + guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count) + return nil + } let workflow = DomainWorkflow( name: trimmedName, @@ -855,7 +867,10 @@ final class DomainViewModel { func runWorkflow(_ workflow: DomainWorkflow) { guard !workflow.domains.isEmpty else { return } - guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return } + guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { + upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count) + return + } startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) } diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift index 5f380bb..d23f348 100644 --- a/DomainDig/FeatureAccessService.swift +++ b/DomainDig/FeatureAccessService.swift @@ -64,34 +64,45 @@ struct FeatureEntitlements: Equatable { let batchSizeLimit: Int? } +struct UpgradePromptContext: Identifiable, Equatable { + let id = UUID() + let title: String + let message: String + let capability: FeatureCapability +} + enum FeatureAccessService { - static let currentTier: FeatureTier = .free + private static let effectivelyUnlimitedTrackedDomains = 5_000 + + static var currentTier: FeatureTier { + PurchaseService.cachedTier + } static var entitlements: FeatureEntitlements { switch currentTier { case .free: return FeatureEntitlements( tier: .free, - capabilities: [.singleLookup, .basicHistory, .limitedTracking], - trackedDomainLimit: 3, - workflowLimit: 0, - batchSizeLimit: 0 + capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations], + trackedDomainLimit: 5, + workflowLimit: 1, + batchSizeLimit: 10 ) case .pro: return FeatureEntitlements( tier: .pro, capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], - trackedDomainLimit: 250, - workflowLimit: 50, - batchSizeLimit: 100 + trackedDomainLimit: effectivelyUnlimitedTrackedDomains, + workflowLimit: nil, + batchSizeLimit: nil ) case .dataPlus: return FeatureEntitlements( tier: .dataPlus, capabilities: Set(FeatureCapability.allCases), - trackedDomainLimit: 1_000, - workflowLimit: 200, - batchSizeLimit: 250 + trackedDomainLimit: effectivelyUnlimitedTrackedDomains, + workflowLimit: nil, + batchSizeLimit: nil ) } } @@ -106,9 +117,10 @@ enum FeatureAccessService { static func trackedDomainLimitMessage(currentCount: Int) -> String? { guard currentTier == .free else { return nil } - return currentCount >= entitlements.trackedDomainLimit - ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." - : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." + if currentCount >= entitlements.trackedDomainLimit { + return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. Available in Pro." + } + return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." } static func canCreateWorkflow(currentCount: Int) -> Bool { @@ -137,19 +149,61 @@ enum FeatureAccessService { } static func workflowLimitMessage(currentCount: Int) -> String? { - guard hasAccess(to: .workflows) else { - return upgradeMessage(for: .workflows) - } guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } - return "Workflow limit reached." + return "Free includes up to \(limit) workflow." } static func batchLimitMessage(domainCount: Int) -> String? { - guard hasAccess(to: .batchOperations) else { - return upgradeMessage(for: .batchOperations) + guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } + return "Free runs batches up to \(limit) domains." + } + + static func workflowAllowanceSummary(currentCount: Int) -> String? { + guard currentTier == .free, let limit = entitlements.workflowLimit else { return nil } + if currentCount >= limit { + return "Free includes up to \(limit) workflow. Available in Pro." } + return "Free includes up to \(limit) workflow." + } + + static func batchAllowanceSummary() -> String? { + guard currentTier == .free, let limit = entitlements.batchSizeLimit else { return nil } + return "Free runs batches up to \(limit) domains." + } + + static func upgradePromptForTrackedDomains(currentCount: Int) -> UpgradePromptContext? { + guard currentCount >= entitlements.trackedDomainLimit else { return nil } + return UpgradePromptContext( + title: "Available in Pro", + message: "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. You can keep your current watchlist, but adding more requires Pro.", + capability: .limitedTracking + ) + } + + static func upgradePromptForWorkflows(currentCount: Int) -> UpgradePromptContext? { + guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } + return UpgradePromptContext( + title: "Available in Pro", + message: "Free includes up to \(limit) workflow. You can keep your current workflows, but creating more requires Pro.", + capability: .workflows + ) + } + + static func upgradePromptForBatch(domainCount: Int) -> UpgradePromptContext? { guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } - return "Batch limit is \(limit) domains on \(currentTier.title)." + return UpgradePromptContext( + title: "Available in Pro", + message: "Free runs batches up to \(limit) domains at a time. You can continue with smaller batches or upgrade to Pro.", + capability: .batchOperations + ) + } + + static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { + UpgradePromptContext( + title: "Available in Pro", + message: upgradeMessage(for: capability), + capability: capability + ) } static func enabledFeatureLabels() -> [String] { diff --git a/DomainDig/PaywallView.swift b/DomainDig/PaywallView.swift new file mode 100644 index 0000000..a21448f --- /dev/null +++ b/DomainDig/PaywallView.swift @@ -0,0 +1,123 @@ +import StoreKit +import SwiftUI + +struct PaywallView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.appDensity) private var appDensity + @State private var purchaseService = PurchaseService.shared + + var body: some View { + NavigationStack { + List { + Section { + Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.") + .font(appDensity.font(.body, design: .default)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Section("What Pro Unlocks") { + featureRow("Unlimited tracked domains") + featureRow("Workflows") + featureRow("Larger batch sizes") + featureRow("Advanced exports") + } + + Section("Subscription") { + if purchaseService.isLoadingProducts { + ProgressView("Loading pricing…") + } else if purchaseService.products.isEmpty { + Text("Pricing is unavailable right now.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + + Button("Retry") { + Task { + await purchaseService.refreshProducts() + } + } + } else { + ForEach(purchaseService.products, id: \.id) { product in + Button { + Task { + await purchaseService.purchase(product) + } + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(subscriptionTitle(for: product)) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + .foregroundStyle(.primary) + Text(product.displayPrice) + .font(appDensity.font(.callout, design: .default)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .disabled(purchaseService.isPurchasing || purchaseService.isRestoring) + } + } + + if let statusMessage = purchaseService.statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + + if let errorMessage = purchaseService.errorMessage { + Text(errorMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.red) + } + } + + Section("Account") { + Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { + Task { + await purchaseService.restorePurchases() + } + } + .disabled(purchaseService.isPurchasing || purchaseService.isRestoring) + + if purchaseService.hasProAccess { + Button("Manage Subscription") { + Task { + await purchaseService.manageSubscription() + } + } + } + } + } + .navigationTitle("DomainDig Pro") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + .task { + await purchaseService.refreshProducts() + await purchaseService.refreshEntitlements() + } + .onDisappear { + purchaseService.clearMessages() + } + } + + private func featureRow(_ title: String) -> some View { + Text(title) + .font(appDensity.font(.body, design: .default)) + } + + private func subscriptionTitle(for product: Product) -> String { + switch product.id { + case PurchaseService.monthlyProductID: + return "Pro Monthly" + case PurchaseService.yearlyProductID: + return "Pro Yearly" + default: + return product.displayName + } + } +} diff --git a/DomainDig/PurchaseService.swift b/DomainDig/PurchaseService.swift new file mode 100644 index 0000000..3d19165 --- /dev/null +++ b/DomainDig/PurchaseService.swift @@ -0,0 +1,245 @@ +import Foundation +import StoreKit + +#if canImport(UIKit) +import UIKit +#endif + +@MainActor +@Observable +final class PurchaseService { + struct CachedEntitlement: Codable { + let tier: FeatureTier + let activeProductID: String? + let updatedAt: Date + } + + static let shared = PurchaseService() + static let monthlyProductID = "domaindig.pro.monthly" + static let yearlyProductID = "domaindig.pro.yearly" + static let productIDs = [monthlyProductID, yearlyProductID] + + private static let entitlementCacheKey = "purchase.cachedEntitlement" + + static var cachedEntitlement: CachedEntitlement? { + guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } + return try? JSONDecoder().decode(CachedEntitlement.self, from: data) + } + + static var cachedTier: FeatureTier { + cachedEntitlement?.tier ?? .free + } + + var products: [Product] = [] + var currentTier: FeatureTier + var activeProductID: String? + var isLoadingProducts = false + var isPurchasing = false + var isRestoring = false + var statusMessage: String? + var errorMessage: String? + + private var updatesTask: Task<Void, Never>? + + private init() { + currentTier = Self.cachedTier + activeProductID = Self.cachedEntitlement?.activeProductID + updatesTask = observeTransactionUpdates() + Task { + await refreshProducts() + await refreshEntitlements() + } + } + + var hasProAccess: Bool { + currentTier == .pro + } + + func refreshProducts() async { + isLoadingProducts = true + errorMessage = nil + + do { + let fetchedProducts = try await Product.products(for: Self.productIDs) + products = fetchedProducts.sorted { lhs, rhs in + productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id) + } + } catch { + products = [] + errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.") + } + + isLoadingProducts = false + } + + func refreshEntitlements() async { + var activeTransactions: [Transaction] = [] + + for await result in Transaction.currentEntitlements { + guard case .verified(let transaction) = result else { + continue + } + guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else { + continue + } + activeTransactions.append(transaction) + } + + let activeProductID = activeTransactions + .sorted { $0.purchaseDate > $1.purchaseDate } + .first? + .productID + + self.activeProductID = activeProductID + currentTier = activeProductID == nil ? .free : .pro + persistCurrentEntitlement() + } + + func purchase(_ product: Product) async { + isPurchasing = true + statusMessage = nil + errorMessage = nil + + do { + let result = try await product.purchase() + + switch result { + case .success(let verification): + let transaction = try verifiedTransaction(from: verification) + apply(transaction: transaction) + await transaction.finish() + await refreshEntitlements() + statusMessage = "Pro is active." + case .userCancelled: + break + case .pending: + statusMessage = "Purchase is pending approval." + @unknown default: + errorMessage = "The purchase could not be completed." + } + } catch { + errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.") + } + + isPurchasing = false + } + + func restorePurchases() async { + isRestoring = true + statusMessage = nil + errorMessage = nil + + do { + try await AppStore.sync() + await refreshEntitlements() + statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found." + } catch { + errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.") + } + + isRestoring = false + } + + func manageSubscription() async { + errorMessage = nil + + #if canImport(UIKit) + if ProcessInfo.processInfo.isiOSAppOnMac { + errorMessage = "Manage Subscription is not available on this device." + return + } + + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) else { + errorMessage = "Manage Subscription is not available right now." + return + } + + do { + try await AppStore.showManageSubscriptions(in: scene) + } catch { + errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.") + } + #else + errorMessage = "Manage Subscription is not available on this platform." + #endif + } + + func clearMessages() { + statusMessage = nil + errorMessage = nil + } + + private func apply(transaction: Transaction) { + guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else { + return + } + + activeProductID = transaction.productID + currentTier = .pro + persistCurrentEntitlement() + } + + private func observeTransactionUpdates() -> Task<Void, Never> { + Task.detached(priority: .background) { [weak self] in + for await result in Transaction.updates { + guard let self else { return } + await self.handleTransactionUpdate(result) + } + } + } + + private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async { + guard case .verified(let transaction) = result else { return } + apply(transaction: transaction) + await transaction.finish() + await refreshEntitlements() + } + + private func persistCurrentEntitlement() { + let cachedEntitlement = CachedEntitlement( + tier: currentTier, + activeProductID: activeProductID, + updatedAt: Date() + ) + + if let data = try? JSONEncoder().encode(cachedEntitlement) { + UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey) + } + } + + private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { + switch result { + case .verified(let transaction): + return transaction + case .unverified: + throw StoreKitError.notEntitled + } + } + + private func productSortIndex(for productID: String) -> Int { + switch productID { + case Self.monthlyProductID: + return 0 + case Self.yearlyProductID: + return 1 + default: + return Int.max + } + } + + private func storeMessage(for error: Error, fallback: String) -> String { + if let storeKitError = error as? StoreKitError { + switch storeKitError { + case .networkError: + return "The App Store is offline right now." + default: + return fallback + } + } + + let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) + return message.isEmpty ? fallback : message + } +} diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift index 19ea444..2d2b8ed 100644 --- a/DomainDig/RootTabView.swift +++ b/DomainDig/RootTabView.swift @@ -2,8 +2,11 @@ import SwiftUI struct RootTabView: View { @Bindable var viewModel: DomainViewModel + @State private var purchaseService = PurchaseService.shared var body: some View { + let _ = purchaseService.currentTier + TabView { ContentView(viewModel: viewModel) .tabItem { @@ -38,5 +41,27 @@ struct RootTabView: View { Label("Settings", systemImage: "gearshape") } } + .sheet(isPresented: Binding( + get: { viewModel.isPaywallPresented }, + set: { viewModel.isPaywallPresented = $0 } + )) { + PaywallView() + } + .alert(item: Binding( + get: { viewModel.upgradePrompt }, + set: { viewModel.upgradePrompt = $0 } + )) { prompt in + Alert( + title: Text(prompt.title), + message: Text(prompt.message), + primaryButton: .default(Text("Open Paywall")) { + viewModel.upgradePrompt = nil + viewModel.isPaywallPresented = true + }, + secondaryButton: .cancel(Text("Continue")) { + viewModel.upgradePrompt = nil + } + ) + } } } diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index fcb5a16..b4e5011 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -4,6 +4,7 @@ struct WatchlistView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @Environment(\.dismiss) private var dismiss + @State private var purchaseService = PurchaseService.shared @State private var showWorkflowAddSheet = false private var pinnedDomains: [TrackedDomain] { @@ -15,6 +16,8 @@ struct WatchlistView: View { } var body: some View { + let _ = purchaseService.currentTier + List { if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { Section("Refresh Progress") { diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift index 9d99b02..5025a0b 100644 --- a/DomainDig/WorkflowsView.swift +++ b/DomainDig/WorkflowsView.swift @@ -10,24 +10,24 @@ private enum WorkflowDestinationMode: String, CaseIterable, Identifiable { struct WorkflowsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel + @State private var purchaseService = PurchaseService.shared @State private var showingCreateWorkflow = false var body: some View { + let _ = purchaseService.currentTier + List { - if FeatureAccessService.hasAccess(to: .workflows) { - workflowContent - } else { + if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) { Section { - EmptyStateCardView( - title: "Workflows", - message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.", - suggestion: FeatureAccessService.upgradeMessage(for: .workflows), - systemImage: "square.stack.3d.down.right" - ) + Text(limitMessage) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) } .listRowBackground(Color(.systemGray6).opacity(0.5)) } + + workflowContent } .scrollContentBackground(.hidden) .background(Color.black) @@ -36,17 +36,15 @@ struct WorkflowsView: View { } .navigationTitle("Workflows") .toolbar { - if FeatureAccessService.hasAccess(to: .workflows) { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showingCreateWorkflow = true - } label: { - Image(systemName: "plus.circle") - } + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showingCreateWorkflow = true + } label: { + Image(systemName: "plus.circle") + } - if !viewModel.workflows.isEmpty { - EditButton() - } + if !viewModel.workflows.isEmpty { + EditButton() } } } @@ -429,10 +427,13 @@ struct WorkflowComposerView: View { private func saveWorkflow() { if let workflow { viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes) - } else { - _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) + dismiss() + return + } + + if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil { + dismiss() } - dismiss() } } @@ -678,10 +679,12 @@ struct WorkflowBulkAddSheet: View { case .existing: guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return } viewModel.addDomains(selectedDomainList, to: workflow) + dismiss() case .new: - _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) + guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else { + return + } + dismiss() } - - dismiss() } } |
