Commit dcf4135ec3
Verified · cmc
Layout: unified · split
DomainDig/AppVersion.swift +1 −1
| @@ -2,6 +2,6 @@ import Foundation | |||
| 2 | 2 | ||
| 3 | enum AppVersion { | 3 | enum AppVersion { |
| 4 | static var current: String { | 4 | static var current: String { |
| 5 | "3.0.0" | 5 | "3.1.0" |
| 6 | } | 6 | } |
| 7 | } | 7 | } |
DomainDig/ContentView.swift +62 −52
| @@ -25,20 +25,21 @@ private struct WorkflowNavigationTarget: Hashable { | |||
| 25 | struct ContentView: View { | 25 | struct ContentView: View { |
| 26 | @Environment(\.appDensity) private var appDensity | 26 | @Environment(\.appDensity) private var appDensity |
| 27 | @Bindable var viewModel: DomainViewModel | 27 | @Bindable var viewModel: DomainViewModel |
| 28 | @State private var purchaseService = PurchaseService.shared | ||
| 28 | @State private var navigationPath = NavigationPath() | 29 | @State private var navigationPath = NavigationPath() |
| 29 | @FocusState private var domainFieldFocused: Bool | 30 | @FocusState private var domainFieldFocused: Bool |
| 30 | @State private var customPortInput = "" | 31 | @State private var customPortInput = "" |
| 31 | @State private var customPortsExpanded = false | 32 | @State private var customPortsExpanded = false |
| 32 | @State private var trackingNoteDraft = "" | 33 | @State private var trackingNoteDraft = "" |
| 33 | @State private var editingTrackedDomain: TrackedDomain? | 34 | @State private var editingTrackedDomain: TrackedDomain? |
| 34 | @State private var showTrackLimitAlert = false | ||
| 35 | @State private var featureGateMessage: String? | ||
| 36 | @State private var inputMode: LookupInputMode = .single | 35 | @State private var inputMode: LookupInputMode = .single |
| 37 | @State private var collapsedSections: Set<ResultSection> = [.network] | 36 | @State private var collapsedSections: Set<ResultSection> = [.network] |
| 38 | @State private var showingCurrentDomainWorkflowSheet = false | 37 | @State private var showingCurrentDomainWorkflowSheet = false |
| 39 | @State private var showingBatchWorkflowSheet = false | 38 | @State private var showingBatchWorkflowSheet = false |
| 40 | 39 | ||
| 41 | var body: some View { | 40 | var body: some View { |
| 41 | let _ = purchaseService.currentTier | ||
| 42 | |||
| 42 | NavigationStack(path: $navigationPath) { | 43 | NavigationStack(path: $navigationPath) { |
| 43 | ScrollView(.vertical) { | 44 | ScrollView(.vertical) { |
| 44 | VStack(spacing: 0) { | 45 | VStack(spacing: 0) { |
| @@ -84,9 +85,7 @@ struct ContentView: View { | |||
| 84 | workflows: viewModel.currentDomainWorkflows, | 85 | workflows: viewModel.currentDomainWorkflows, |
| 85 | trackingLimitMessage: viewModel.trackingLimitMessage, | 86 | trackingLimitMessage: viewModel.trackingLimitMessage, |
| 86 | onTrack: { | 87 | onTrack: { |
| 87 | if !viewModel.trackCurrentDomain() { | 88 | _ = viewModel.trackCurrentDomain() |
| 88 | showTrackLimitAlert = true | ||
| 89 | } | ||
| 90 | }, | 89 | }, |
| 91 | onTogglePinned: { | 90 | onTogglePinned: { |
| 92 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } | 91 | guard let trackedDomain = viewModel.currentTrackedDomain else { return } |
| @@ -248,23 +247,6 @@ struct ContentView: View { | |||
| 248 | navigationPath = NavigationPath() | 247 | navigationPath = NavigationPath() |
| 249 | domainFieldFocused = false | 248 | domainFieldFocused = false |
| 250 | } | 249 | } |
| 251 | .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { | ||
| 252 | Button("OK", role: .cancel) {} | ||
| 253 | } message: { | ||
| 254 | Text(viewModel.trackingLimitMessage ?? "Tracking limit reached.") | ||
| 255 | } | ||
| 256 | .alert("Feature unavailable", isPresented: Binding( | ||
| 257 | get: { featureGateMessage != nil }, | ||
| 258 | set: { newValue in | ||
| 259 | if !newValue { | ||
| 260 | featureGateMessage = nil | ||
| 261 | } | ||
| 262 | } | ||
| 263 | )) { | ||
| 264 | Button("OK", role: .cancel) {} | ||
| 265 | } message: { | ||
| 266 | Text(featureGateMessage ?? "") | ||
| 267 | } | ||
| 268 | .sheet(item: $editingTrackedDomain) { trackedDomain in | 250 | .sheet(item: $editingTrackedDomain) { trackedDomain in |
| 269 | NavigationStack { | 251 | NavigationStack { |
| 270 | Form { | 252 | Form { |
| @@ -346,6 +328,12 @@ struct ContentView: View { | |||
| 346 | .font(appDensity.font(.caption)) | 328 | .font(appDensity.font(.caption)) |
| 347 | .foregroundStyle(.secondary) | 329 | .foregroundStyle(.secondary) |
| 348 | 330 | ||
| 331 | if let batchAllowanceSummary = FeatureAccessService.batchAllowanceSummary() { | ||
| 332 | Text(batchAllowanceSummary) | ||
| 333 | .font(appDensity.font(.caption2)) | ||
| 334 | .foregroundStyle(.secondary) | ||
| 335 | } | ||
| 336 | |||
| 349 | TextField( | 337 | TextField( |
| 350 | "example.com\napple.com, openai.com", | 338 | "example.com\napple.com, openai.com", |
| 351 | text: $viewModel.bulkInput, | 339 | text: $viewModel.bulkInput, |
| @@ -391,25 +379,25 @@ struct ContentView: View { | |||
| 391 | Menu { | 379 | Menu { |
| 392 | if !viewModel.isCurrentDomainTracked { | 380 | if !viewModel.isCurrentDomainTracked { |
| 393 | Button("Track this domain") { | 381 | Button("Track this domain") { |
| 394 | if !viewModel.trackCurrentDomain() { | 382 | _ = viewModel.trackCurrentDomain() |
| 395 | showTrackLimitAlert = true | ||
| 396 | } | ||
| 397 | } | 383 | } |
| 398 | } | 384 | } |
| 399 | Button("Add to workflow") { | 385 | Button("Add to workflow") { |
| 400 | if FeatureAccessService.hasAccess(to: .workflows) { | 386 | showingCurrentDomainWorkflowSheet = true |
| 401 | showingCurrentDomainWorkflowSheet = true | ||
| 402 | } else { | ||
| 403 | featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) | ||
| 404 | } | ||
| 405 | } | 387 | } |
| 406 | Button("Copy report JSON") { | 388 | if FeatureAccessService.hasAccess(to: .advancedExports) { |
| 407 | guard let json = viewModel.exportJSONString() else { return } | 389 | Button("Copy report JSON") { |
| 408 | AppClipboard.copy(json) | 390 | guard let json = viewModel.exportJSONString() else { return } |
| 409 | AppHaptics.copy() | 391 | AppClipboard.copy(json) |
| 392 | AppHaptics.copy() | ||
| 393 | } | ||
| 394 | } else { | ||
| 395 | Button("Copy report JSON") { | ||
| 396 | viewModel.upgradePrompt = FeatureAccessService.upgradePrompt(for: .advancedExports) | ||
| 397 | } | ||
| 410 | } | 398 | } |
| 411 | Button("Export report") { | 399 | Button("Export report") { |
| 412 | shareSingleResults(format: .json) | 400 | shareSingleResults(format: .text) |
| 413 | } | 401 | } |
| 414 | } label: { | 402 | } label: { |
| 415 | Image(systemName: "bolt.circle") | 403 | Image(systemName: "bolt.circle") |
| @@ -463,11 +451,7 @@ struct ContentView: View { | |||
| 463 | if !viewModel.currentBatchResultEntries.isEmpty { | 451 | if !viewModel.currentBatchResultEntries.isEmpty { |
| 464 | Menu { | 452 | Menu { |
| 465 | Button("Add to Workflow") { | 453 | Button("Add to Workflow") { |
| 466 | if FeatureAccessService.hasAccess(to: .workflows) { | 454 | showingBatchWorkflowSheet = true |
| 467 | showingBatchWorkflowSheet = true | ||
| 468 | } else { | ||
| 469 | featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) | ||
| 470 | } | ||
| 471 | } | 455 | } |
| 472 | Divider() | 456 | Divider() |
| 473 | Button("Export Batch TXT") { | 457 | Button("Export Batch TXT") { |
| @@ -2165,6 +2149,7 @@ private extension String { | |||
| 2165 | struct SettingsView: View { | 2149 | struct SettingsView: View { |
| 2166 | @Environment(\.appDensity) private var appDensity | 2150 | @Environment(\.appDensity) private var appDensity |
| 2167 | @Bindable var viewModel: DomainViewModel | 2151 | @Bindable var viewModel: DomainViewModel |
| 2152 | @State private var purchaseService = PurchaseService.shared | ||
| 2168 | @AppStorage(DNSResolverOption.userDefaultsKey) | 2153 | @AppStorage(DNSResolverOption.userDefaultsKey) |
| 2169 | private var storedResolverURL = DNSResolverOption.defaultURLString | 2154 | private var storedResolverURL = DNSResolverOption.defaultURLString |
| 2170 | @AppStorage(AppDensity.userDefaultsKey) | 2155 | @AppStorage(AppDensity.userDefaultsKey) |
| @@ -2185,6 +2170,8 @@ struct SettingsView: View { | |||
| 2185 | } | 2170 | } |
| 2186 | 2171 | ||
| 2187 | var body: some View { | 2172 | var body: some View { |
| 2173 | let _ = purchaseService.currentTier | ||
| 2174 | |||
| 2188 | Form { | 2175 | Form { |
| 2189 | Section("Display") { | 2176 | Section("Display") { |
| 2190 | Picker("Density", selection: $storedDensity) { | 2177 | Picker("Density", selection: $storedDensity) { |
| @@ -2233,28 +2220,51 @@ struct SettingsView: View { | |||
| 2233 | } | 2220 | } |
| 2234 | } | 2221 | } |
| 2235 | 2222 | ||
| 2236 | Section("Features") { | 2223 | Section("Pro") { |
| 2237 | LabeledContent("Tier", value: FeatureAccessService.currentTier.title) | 2224 | LabeledContent("Status", value: purchaseService.currentTier.title) |
| 2238 | 2225 | ||
| 2239 | if FeatureAccessService.enabledFeatureLabels().isEmpty { | 2226 | if purchaseService.currentTier == .free { |
| 2240 | Text("No features enabled.") | 2227 | Button("Upgrade to Pro") { |
| 2241 | .font(appDensity.font(.caption, design: .default)) | 2228 | viewModel.isPaywallPresented = true |
| 2242 | .foregroundStyle(.secondary) | 2229 | } |
| 2243 | } else { | 2230 | } else { |
| 2244 | ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in | 2231 | Button("Manage Subscription") { |
| 2245 | Text(label) | 2232 | Task { |
| 2233 | await purchaseService.manageSubscription() | ||
| 2234 | } | ||
| 2246 | } | 2235 | } |
| 2247 | } | 2236 | } |
| 2248 | 2237 | ||
| 2249 | Text("Workflows, batch operations, and advanced exports are prepared for future Pro unlocks. Extended historical datasets are reserved for Data+ scaffolding.") | 2238 | Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { |
| 2250 | .font(appDensity.font(.caption, design: .default)) | 2239 | Task { |
| 2251 | .foregroundStyle(.secondary) | 2240 | await purchaseService.restorePurchases() |
| 2241 | } | ||
| 2242 | } | ||
| 2243 | .disabled(purchaseService.isRestoring || purchaseService.isPurchasing) | ||
| 2244 | |||
| 2245 | if let statusMessage = purchaseService.statusMessage { | ||
| 2246 | Text(statusMessage) | ||
| 2247 | .font(appDensity.font(.caption, design: .default)) | ||
| 2248 | .foregroundStyle(.secondary) | ||
| 2249 | } | ||
| 2250 | |||
| 2251 | if let errorMessage = purchaseService.errorMessage { | ||
| 2252 | Text(errorMessage) | ||
| 2253 | .font(appDensity.font(.caption, design: .default)) | ||
| 2254 | .foregroundStyle(.red) | ||
| 2255 | } | ||
| 2256 | } | ||
| 2257 | |||
| 2258 | Section("Features") { | ||
| 2259 | ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in | ||
| 2260 | Text(label) | ||
| 2261 | } | ||
| 2252 | } | 2262 | } |
| 2253 | 2263 | ||
| 2254 | Section("About") { | 2264 | Section("About") { |
| 2255 | LabeledContent("Version", value: appVersion) | 2265 | LabeledContent("Version", value: appVersion) |
| 2256 | LabeledContent("Storage", value: "Local-only") | 2266 | LabeledContent("Storage", value: "Local-only") |
| 2257 | LabeledContent("Report Schema", value: "3.0.0") | 2267 | LabeledContent("Report Schema", value: "3.1.0") |
| 2258 | } | 2268 | } |
| 2259 | } | 2269 | } |
| 2260 | .navigationTitle("Settings") | 2270 | .navigationTitle("Settings") |
DomainDig/DomainDigApp.swift +5
| @@ -11,6 +11,7 @@ import SwiftUI | |||
| 11 | struct DomainDigApp: App { | 11 | struct DomainDigApp: App { |
| 12 | @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue | 12 | @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue |
| 13 | @State private var viewModel = DomainViewModel() | 13 | @State private var viewModel = DomainViewModel() |
| 14 | @State private var purchaseService = PurchaseService.shared | ||
| 14 | 15 | ||
| 15 | init() { | 16 | init() { |
| 16 | LocalNotificationService.shared.configureForegroundPresentation() | 17 | LocalNotificationService.shared.configureForegroundPresentation() |
| @@ -20,6 +21,10 @@ struct DomainDigApp: App { | |||
| 20 | WindowGroup { | 21 | WindowGroup { |
| 21 | RootTabView(viewModel: viewModel) | 22 | RootTabView(viewModel: viewModel) |
| 22 | .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) | 23 | .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) |
| 24 | .task { | ||
| 25 | let _ = purchaseService.currentTier | ||
| 26 | await purchaseService.refreshEntitlements() | ||
| 27 | } | ||
| 23 | } | 28 | } |
| 24 | } | 29 | } |
| 25 | } | 30 | } |
DomainDig/DomainViewModel.swift +19 −4
| @@ -226,6 +226,8 @@ final class DomainViewModel { | |||
| 226 | var watchlistSearchText = "" | 226 | var watchlistSearchText = "" |
| 227 | var watchlistFilter: WatchlistFilterOption = .all | 227 | var watchlistFilter: WatchlistFilterOption = .all |
| 228 | var watchlistSortOption: WatchlistSortOption = .pinned | 228 | var watchlistSortOption: WatchlistSortOption = .pinned |
| 229 | var upgradePrompt: UpgradePromptContext? | ||
| 230 | var isPaywallPresented = false | ||
| 229 | 231 | ||
| 230 | var trimmedDomain: String { | 232 | var trimmedDomain: String { |
| 231 | domain | 233 | domain |
| @@ -578,6 +580,7 @@ final class DomainViewModel { | |||
| 578 | } | 580 | } |
| 579 | 581 | ||
| 580 | guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { | 582 | guard PremiumAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) else { |
| 583 | upgradePrompt = FeatureAccessService.upgradePromptForTrackedDomains(currentCount: trackedDomains.count) | ||
| 581 | return false | 584 | return false |
| 582 | } | 585 | } |
| 583 | 586 | ||
| @@ -737,12 +740,18 @@ final class DomainViewModel { | |||
| 737 | func runBulkLookup() { | 740 | func runBulkLookup() { |
| 738 | let domains = parsedDomains(from: bulkInput) | 741 | let domains = parsedDomains(from: bulkInput) |
| 739 | guard !domains.isEmpty else { return } | 742 | guard !domains.isEmpty else { return } |
| 740 | guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return } | 743 | guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { |
| 744 | upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: domains.count) | ||
| 745 | return | ||
| 746 | } | ||
| 741 | startBatchLookup(domains: domains, source: .manual) | 747 | startBatchLookup(domains: domains, source: .manual) |
| 742 | } | 748 | } |
| 743 | 749 | ||
| 744 | func refreshAllTrackedDomains() { | 750 | func refreshAllTrackedDomains() { |
| 745 | guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return } | 751 | guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { |
| 752 | upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: sortedTrackedDomains.count) | ||
| 753 | return | ||
| 754 | } | ||
| 746 | startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) | 755 | startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) |
| 747 | } | 756 | } |
| 748 | 757 | ||
| @@ -791,7 +800,10 @@ final class DomainViewModel { | |||
| 791 | let normalizedDomains = normalizedDomains(domains) | 800 | let normalizedDomains = normalizedDomains(domains) |
| 792 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) | 801 | let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) |
| 793 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } | 802 | guard !trimmedName.isEmpty, !normalizedDomains.isEmpty else { return nil } |
| 794 | guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { return nil } | 803 | guard FeatureAccessService.canCreateWorkflow(currentCount: workflows.count) else { |
| 804 | upgradePrompt = FeatureAccessService.upgradePromptForWorkflows(currentCount: workflows.count) | ||
| 805 | return nil | ||
| 806 | } | ||
| 795 | 807 | ||
| 796 | let workflow = DomainWorkflow( | 808 | let workflow = DomainWorkflow( |
| 797 | name: trimmedName, | 809 | name: trimmedName, |
| @@ -855,7 +867,10 @@ final class DomainViewModel { | |||
| 855 | 867 | ||
| 856 | func runWorkflow(_ workflow: DomainWorkflow) { | 868 | func runWorkflow(_ workflow: DomainWorkflow) { |
| 857 | guard !workflow.domains.isEmpty else { return } | 869 | guard !workflow.domains.isEmpty else { return } |
| 858 | guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return } | 870 | guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { |
| 871 | upgradePrompt = FeatureAccessService.upgradePromptForBatch(domainCount: workflow.domains.count) | ||
| 872 | return | ||
| 873 | } | ||
| 859 | startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) | 874 | startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) |
| 860 | } | 875 | } |
| 861 | 876 | ||
DomainDig/FeatureAccessService.swift +75 −21
| @@ -64,34 +64,45 @@ struct FeatureEntitlements: Equatable { | |||
| 64 | let batchSizeLimit: Int? | 64 | let batchSizeLimit: Int? |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | struct UpgradePromptContext: Identifiable, Equatable { | ||
| 68 | let id = UUID() | ||
| 69 | let title: String | ||
| 70 | let message: String | ||
| 71 | let capability: FeatureCapability | ||
| 72 | } | ||
| 73 | |||
| 67 | enum FeatureAccessService { | 74 | enum FeatureAccessService { |
| 68 | static let currentTier: FeatureTier = .free | 75 | private static let effectivelyUnlimitedTrackedDomains = 5_000 |
| 76 | |||
| 77 | static var currentTier: FeatureTier { | ||
| 78 | PurchaseService.cachedTier | ||
| 79 | } | ||
| 69 | 80 | ||
| 70 | static var entitlements: FeatureEntitlements { | 81 | static var entitlements: FeatureEntitlements { |
| 71 | switch currentTier { | 82 | switch currentTier { |
| 72 | case .free: | 83 | case .free: |
| 73 | return FeatureEntitlements( | 84 | return FeatureEntitlements( |
| 74 | tier: .free, | 85 | tier: .free, |
| 75 | capabilities: [.singleLookup, .basicHistory, .limitedTracking], | 86 | capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations], |
| 76 | trackedDomainLimit: 3, | 87 | trackedDomainLimit: 5, |
| 77 | workflowLimit: 0, | 88 | workflowLimit: 1, |
| 78 | batchSizeLimit: 0 | 89 | batchSizeLimit: 10 |
| 79 | ) | 90 | ) |
| 80 | case .pro: | 91 | case .pro: |
| 81 | return FeatureEntitlements( | 92 | return FeatureEntitlements( |
| 82 | tier: .pro, | 93 | tier: .pro, |
| 83 | capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], | 94 | capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], |
| 84 | trackedDomainLimit: 250, | 95 | trackedDomainLimit: effectivelyUnlimitedTrackedDomains, |
| 85 | workflowLimit: 50, | 96 | workflowLimit: nil, |
| 86 | batchSizeLimit: 100 | 97 | batchSizeLimit: nil |
| 87 | ) | 98 | ) |
| 88 | case .dataPlus: | 99 | case .dataPlus: |
| 89 | return FeatureEntitlements( | 100 | return FeatureEntitlements( |
| 90 | tier: .dataPlus, | 101 | tier: .dataPlus, |
| 91 | capabilities: Set(FeatureCapability.allCases), | 102 | capabilities: Set(FeatureCapability.allCases), |
| 92 | trackedDomainLimit: 1_000, | 103 | trackedDomainLimit: effectivelyUnlimitedTrackedDomains, |
| 93 | workflowLimit: 200, | 104 | workflowLimit: nil, |
| 94 | batchSizeLimit: 250 | 105 | batchSizeLimit: nil |
| 95 | ) | 106 | ) |
| 96 | } | 107 | } |
| 97 | } | 108 | } |
| @@ -106,9 +117,10 @@ enum FeatureAccessService { | |||
| 106 | 117 | ||
| 107 | static func trackedDomainLimitMessage(currentCount: Int) -> String? { | 118 | static func trackedDomainLimitMessage(currentCount: Int) -> String? { |
| 108 | guard currentTier == .free else { return nil } | 119 | guard currentTier == .free else { return nil } |
| 109 | return currentCount >= entitlements.trackedDomainLimit | 120 | if currentCount >= entitlements.trackedDomainLimit { |
| 110 | ? "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." | 121 | return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. Available in Pro." |
| 111 | : "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." | 122 | } |
| 123 | return "Free includes up to \(entitlements.trackedDomainLimit) tracked domains." | ||
| 112 | } | 124 | } |
| 113 | 125 | ||
| 114 | static func canCreateWorkflow(currentCount: Int) -> Bool { | 126 | static func canCreateWorkflow(currentCount: Int) -> Bool { |
| @@ -137,19 +149,61 @@ enum FeatureAccessService { | |||
| 137 | } | 149 | } |
| 138 | 150 | ||
| 139 | static func workflowLimitMessage(currentCount: Int) -> String? { | 151 | static func workflowLimitMessage(currentCount: Int) -> String? { |
| 140 | guard hasAccess(to: .workflows) else { | ||
| 141 | return upgradeMessage(for: .workflows) | ||
| 142 | } | ||
| 143 | guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } | 152 | guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } |
| 144 | return "Workflow limit reached." | 153 | return "Free includes up to \(limit) workflow." |
| 145 | } | 154 | } |
| 146 | 155 | ||
| 147 | static func batchLimitMessage(domainCount: Int) -> String? { | 156 | static func batchLimitMessage(domainCount: Int) -> String? { |
| 148 | guard hasAccess(to: .batchOperations) else { | 157 | guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } |
| 149 | return upgradeMessage(for: .batchOperations) | 158 | return "Free runs batches up to \(limit) domains." |
| 159 | } | ||
| 160 | |||
| 161 | static func workflowAllowanceSummary(currentCount: Int) -> String? { | ||
| 162 | guard currentTier == .free, let limit = entitlements.workflowLimit else { return nil } | ||
| 163 | if currentCount >= limit { | ||
| 164 | return "Free includes up to \(limit) workflow. Available in Pro." | ||
| 150 | } | 165 | } |
| 166 | return "Free includes up to \(limit) workflow." | ||
| 167 | } | ||
| 168 | |||
| 169 | static func batchAllowanceSummary() -> String? { | ||
| 170 | guard currentTier == .free, let limit = entitlements.batchSizeLimit else { return nil } | ||
| 171 | return "Free runs batches up to \(limit) domains." | ||
| 172 | } | ||
| 173 | |||
| 174 | static func upgradePromptForTrackedDomains(currentCount: Int) -> UpgradePromptContext? { | ||
| 175 | guard currentCount >= entitlements.trackedDomainLimit else { return nil } | ||
| 176 | return UpgradePromptContext( | ||
| 177 | title: "Available in Pro", | ||
| 178 | message: "Free includes up to \(entitlements.trackedDomainLimit) tracked domains. You can keep your current watchlist, but adding more requires Pro.", | ||
| 179 | capability: .limitedTracking | ||
| 180 | ) | ||
| 181 | } | ||
| 182 | |||
| 183 | static func upgradePromptForWorkflows(currentCount: Int) -> UpgradePromptContext? { | ||
| 184 | guard let limit = entitlements.workflowLimit, currentCount >= limit else { return nil } | ||
| 185 | return UpgradePromptContext( | ||
| 186 | title: "Available in Pro", | ||
| 187 | message: "Free includes up to \(limit) workflow. You can keep your current workflows, but creating more requires Pro.", | ||
| 188 | capability: .workflows | ||
| 189 | ) | ||
| 190 | } | ||
| 191 | |||
| 192 | static func upgradePromptForBatch(domainCount: Int) -> UpgradePromptContext? { | ||
| 151 | guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } | 193 | guard let limit = entitlements.batchSizeLimit, domainCount > limit else { return nil } |
| 152 | return "Batch limit is \(limit) domains on \(currentTier.title)." | 194 | return UpgradePromptContext( |
| 195 | title: "Available in Pro", | ||
| 196 | message: "Free runs batches up to \(limit) domains at a time. You can continue with smaller batches or upgrade to Pro.", | ||
| 197 | capability: .batchOperations | ||
| 198 | ) | ||
| 199 | } | ||
| 200 | |||
| 201 | static func upgradePrompt(for capability: FeatureCapability) -> UpgradePromptContext { | ||
| 202 | UpgradePromptContext( | ||
| 203 | title: "Available in Pro", | ||
| 204 | message: upgradeMessage(for: capability), | ||
| 205 | capability: capability | ||
| 206 | ) | ||
| 153 | } | 207 | } |
| 154 | 208 | ||
| 155 | static func enabledFeatureLabels() -> [String] { | 209 | static func enabledFeatureLabels() -> [String] { |
DomainDig/PaywallView.swift added +123
| @@ -0,0 +1,123 @@ | |||
| 1 | import StoreKit | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | struct PaywallView: View { | ||
| 5 | @Environment(\.dismiss) private var dismiss | ||
| 6 | @Environment(\.appDensity) private var appDensity | ||
| 7 | @State private var purchaseService = PurchaseService.shared | ||
| 8 | |||
| 9 | var body: some View { | ||
| 10 | NavigationStack { | ||
| 11 | List { | ||
| 12 | Section { | ||
| 13 | Text("Pro unlocks automation, larger runs, and export convenience while keeping the core lookup experience free.") | ||
| 14 | .font(appDensity.font(.body, design: .default)) | ||
| 15 | .foregroundStyle(.secondary) | ||
| 16 | .fixedSize(horizontal: false, vertical: true) | ||
| 17 | } | ||
| 18 | |||
| 19 | Section("What Pro Unlocks") { | ||
| 20 | featureRow("Unlimited tracked domains") | ||
| 21 | featureRow("Workflows") | ||
| 22 | featureRow("Larger batch sizes") | ||
| 23 | featureRow("Advanced exports") | ||
| 24 | } | ||
| 25 | |||
| 26 | Section("Subscription") { | ||
| 27 | if purchaseService.isLoadingProducts { | ||
| 28 | ProgressView("Loading pricing…") | ||
| 29 | } else if purchaseService.products.isEmpty { | ||
| 30 | Text("Pricing is unavailable right now.") | ||
| 31 | .font(appDensity.font(.caption, design: .default)) | ||
| 32 | .foregroundStyle(.secondary) | ||
| 33 | |||
| 34 | Button("Retry") { | ||
| 35 | Task { | ||
| 36 | await purchaseService.refreshProducts() | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } else { | ||
| 40 | ForEach(purchaseService.products, id: \.id) { product in | ||
| 41 | Button { | ||
| 42 | Task { | ||
| 43 | await purchaseService.purchase(product) | ||
| 44 | } | ||
| 45 | } label: { | ||
| 46 | VStack(alignment: .leading, spacing: 6) { | ||
| 47 | Text(subscriptionTitle(for: product)) | ||
| 48 | .font(appDensity.font(.headline, design: .default, weight: .semibold)) | ||
| 49 | .foregroundStyle(.primary) | ||
| 50 | Text(product.displayPrice) | ||
| 51 | .font(appDensity.font(.callout, design: .default)) | ||
| 52 | .foregroundStyle(.secondary) | ||
| 53 | } | ||
| 54 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 55 | } | ||
| 56 | .disabled(purchaseService.isPurchasing || purchaseService.isRestoring) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | if let statusMessage = purchaseService.statusMessage { | ||
| 61 | Text(statusMessage) | ||
| 62 | .font(appDensity.font(.caption, design: .default)) | ||
| 63 | .foregroundStyle(.secondary) | ||
| 64 | } | ||
| 65 | |||
| 66 | if let errorMessage = purchaseService.errorMessage { | ||
| 67 | Text(errorMessage) | ||
| 68 | .font(appDensity.font(.caption, design: .default)) | ||
| 69 | .foregroundStyle(.red) | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | Section("Account") { | ||
| 74 | Button(purchaseService.isRestoring ? "Restoring…" : "Restore Purchases") { | ||
| 75 | Task { | ||
| 76 | await purchaseService.restorePurchases() | ||
| 77 | } | ||
| 78 | } | ||
| 79 | .disabled(purchaseService.isPurchasing || purchaseService.isRestoring) | ||
| 80 | |||
| 81 | if purchaseService.hasProAccess { | ||
| 82 | Button("Manage Subscription") { | ||
| 83 | Task { | ||
| 84 | await purchaseService.manageSubscription() | ||
| 85 | } | ||
| 86 | } | ||
| 87 | } | ||
| 88 | } | ||
| 89 | } | ||
| 90 | .navigationTitle("DomainDig Pro") | ||
| 91 | .toolbar { | ||
| 92 | ToolbarItem(placement: .topBarTrailing) { | ||
| 93 | Button("Done") { | ||
| 94 | dismiss() | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
| 98 | } | ||
| 99 | .task { | ||
| 100 | await purchaseService.refreshProducts() | ||
| 101 | await purchaseService.refreshEntitlements() | ||
| 102 | } | ||
| 103 | .onDisappear { | ||
| 104 | purchaseService.clearMessages() | ||
| 105 | } | ||
| 106 | } | ||
| 107 | |||
| 108 | private func featureRow(_ title: String) -> some View { | ||
| 109 | Text(title) | ||
| 110 | .font(appDensity.font(.body, design: .default)) | ||
| 111 | } | ||
| 112 | |||
| 113 | private func subscriptionTitle(for product: Product) -> String { | ||
| 114 | switch product.id { | ||
| 115 | case PurchaseService.monthlyProductID: | ||
| 116 | return "Pro Monthly" | ||
| 117 | case PurchaseService.yearlyProductID: | ||
| 118 | return "Pro Yearly" | ||
| 119 | default: | ||
| 120 | return product.displayName | ||
| 121 | } | ||
| 122 | } | ||
| 123 | } | ||
DomainDig/PurchaseService.swift added +245
| @@ -0,0 +1,245 @@ | |||
| 1 | import Foundation | ||
| 2 | import StoreKit | ||
| 3 | |||
| 4 | #if canImport(UIKit) | ||
| 5 | import UIKit | ||
| 6 | #endif | ||
| 7 | |||
| 8 | @MainActor | ||
| 9 | @Observable | ||
| 10 | final class PurchaseService { | ||
| 11 | struct CachedEntitlement: Codable { | ||
| 12 | let tier: FeatureTier | ||
| 13 | let activeProductID: String? | ||
| 14 | let updatedAt: Date | ||
| 15 | } | ||
| 16 | |||
| 17 | static let shared = PurchaseService() | ||
| 18 | static let monthlyProductID = "domaindig.pro.monthly" | ||
| 19 | static let yearlyProductID = "domaindig.pro.yearly" | ||
| 20 | static let productIDs = [monthlyProductID, yearlyProductID] | ||
| 21 | |||
| 22 | private static let entitlementCacheKey = "purchase.cachedEntitlement" | ||
| 23 | |||
| 24 | static var cachedEntitlement: CachedEntitlement? { | ||
| 25 | guard let data = UserDefaults.standard.data(forKey: entitlementCacheKey) else { return nil } | ||
| 26 | return try? JSONDecoder().decode(CachedEntitlement.self, from: data) | ||
| 27 | } | ||
| 28 | |||
| 29 | static var cachedTier: FeatureTier { | ||
| 30 | cachedEntitlement?.tier ?? .free | ||
| 31 | } | ||
| 32 | |||
| 33 | var products: [Product] = [] | ||
| 34 | var currentTier: FeatureTier | ||
| 35 | var activeProductID: String? | ||
| 36 | var isLoadingProducts = false | ||
| 37 | var isPurchasing = false | ||
| 38 | var isRestoring = false | ||
| 39 | var statusMessage: String? | ||
| 40 | var errorMessage: String? | ||
| 41 | |||
| 42 | private var updatesTask: Task<Void, Never>? | ||
| 43 | |||
| 44 | private init() { | ||
| 45 | currentTier = Self.cachedTier | ||
| 46 | activeProductID = Self.cachedEntitlement?.activeProductID | ||
| 47 | updatesTask = observeTransactionUpdates() | ||
| 48 | Task { | ||
| 49 | await refreshProducts() | ||
| 50 | await refreshEntitlements() | ||
| 51 | } | ||
| 52 | } | ||
| 53 | |||
| 54 | var hasProAccess: Bool { | ||
| 55 | currentTier == .pro | ||
| 56 | } | ||
| 57 | |||
| 58 | func refreshProducts() async { | ||
| 59 | isLoadingProducts = true | ||
| 60 | errorMessage = nil | ||
| 61 | |||
| 62 | do { | ||
| 63 | let fetchedProducts = try await Product.products(for: Self.productIDs) | ||
| 64 | products = fetchedProducts.sorted { lhs, rhs in | ||
| 65 | productSortIndex(for: lhs.id) < productSortIndex(for: rhs.id) | ||
| 66 | } | ||
| 67 | } catch { | ||
| 68 | products = [] | ||
| 69 | errorMessage = storeMessage(for: error, fallback: "Pricing is unavailable right now.") | ||
| 70 | } | ||
| 71 | |||
| 72 | isLoadingProducts = false | ||
| 73 | } | ||
| 74 | |||
| 75 | func refreshEntitlements() async { | ||
| 76 | var activeTransactions: [Transaction] = [] | ||
| 77 | |||
| 78 | for await result in Transaction.currentEntitlements { | ||
| 79 | guard case .verified(let transaction) = result else { | ||
| 80 | continue | ||
| 81 | } | ||
| 82 | guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else { | ||
| 83 | continue | ||
| 84 | } | ||
| 85 | activeTransactions.append(transaction) | ||
| 86 | } | ||
| 87 | |||
| 88 | let activeProductID = activeTransactions | ||
| 89 | .sorted { $0.purchaseDate > $1.purchaseDate } | ||
| 90 | .first? | ||
| 91 | .productID | ||
| 92 | |||
| 93 | self.activeProductID = activeProductID | ||
| 94 | currentTier = activeProductID == nil ? .free : .pro | ||
| 95 | persistCurrentEntitlement() | ||
| 96 | } | ||
| 97 | |||
| 98 | func purchase(_ product: Product) async { | ||
| 99 | isPurchasing = true | ||
| 100 | statusMessage = nil | ||
| 101 | errorMessage = nil | ||
| 102 | |||
| 103 | do { | ||
| 104 | let result = try await product.purchase() | ||
| 105 | |||
| 106 | switch result { | ||
| 107 | case .success(let verification): | ||
| 108 | let transaction = try verifiedTransaction(from: verification) | ||
| 109 | apply(transaction: transaction) | ||
| 110 | await transaction.finish() | ||
| 111 | await refreshEntitlements() | ||
| 112 | statusMessage = "Pro is active." | ||
| 113 | case .userCancelled: | ||
| 114 | break | ||
| 115 | case .pending: | ||
| 116 | statusMessage = "Purchase is pending approval." | ||
| 117 | @unknown default: | ||
| 118 | errorMessage = "The purchase could not be completed." | ||
| 119 | } | ||
| 120 | } catch { | ||
| 121 | errorMessage = storeMessage(for: error, fallback: "The purchase could not be completed.") | ||
| 122 | } | ||
| 123 | |||
| 124 | isPurchasing = false | ||
| 125 | } | ||
| 126 | |||
| 127 | func restorePurchases() async { | ||
| 128 | isRestoring = true | ||
| 129 | statusMessage = nil | ||
| 130 | errorMessage = nil | ||
| 131 | |||
| 132 | do { | ||
| 133 | try await AppStore.sync() | ||
| 134 | await refreshEntitlements() | ||
| 135 | statusMessage = hasProAccess ? "Purchases restored." : "No previous Pro purchase was found." | ||
| 136 | } catch { | ||
| 137 | errorMessage = storeMessage(for: error, fallback: "Restore failed. Try again when the App Store is available.") | ||
| 138 | } | ||
| 139 | |||
| 140 | isRestoring = false | ||
| 141 | } | ||
| 142 | |||
| 143 | func manageSubscription() async { | ||
| 144 | errorMessage = nil | ||
| 145 | |||
| 146 | #if canImport(UIKit) | ||
| 147 | if ProcessInfo.processInfo.isiOSAppOnMac { | ||
| 148 | errorMessage = "Manage Subscription is not available on this device." | ||
| 149 | return | ||
| 150 | } | ||
| 151 | |||
| 152 | guard let scene = UIApplication.shared.connectedScenes | ||
| 153 | .compactMap({ $0 as? UIWindowScene }) | ||
| 154 | .first(where: { $0.activationState == .foregroundActive }) else { | ||
| 155 | errorMessage = "Manage Subscription is not available right now." | ||
| 156 | return | ||
| 157 | } | ||
| 158 | |||
| 159 | do { | ||
| 160 | try await AppStore.showManageSubscriptions(in: scene) | ||
| 161 | } catch { | ||
| 162 | errorMessage = storeMessage(for: error, fallback: "Manage Subscription is not available right now.") | ||
| 163 | } | ||
| 164 | #else | ||
| 165 | errorMessage = "Manage Subscription is not available on this platform." | ||
| 166 | #endif | ||
| 167 | } | ||
| 168 | |||
| 169 | func clearMessages() { | ||
| 170 | statusMessage = nil | ||
| 171 | errorMessage = nil | ||
| 172 | } | ||
| 173 | |||
| 174 | private func apply(transaction: Transaction) { | ||
| 175 | guard Self.productIDs.contains(transaction.productID), transaction.revocationDate == nil else { | ||
| 176 | return | ||
| 177 | } | ||
| 178 | |||
| 179 | activeProductID = transaction.productID | ||
| 180 | currentTier = .pro | ||
| 181 | persistCurrentEntitlement() | ||
| 182 | } | ||
| 183 | |||
| 184 | private func observeTransactionUpdates() -> Task<Void, Never> { | ||
| 185 | Task.detached(priority: .background) { [weak self] in | ||
| 186 | for await result in Transaction.updates { | ||
| 187 | guard let self else { return } | ||
| 188 | await self.handleTransactionUpdate(result) | ||
| 189 | } | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 193 | private func handleTransactionUpdate(_ result: VerificationResult<Transaction>) async { | ||
| 194 | guard case .verified(let transaction) = result else { return } | ||
| 195 | apply(transaction: transaction) | ||
| 196 | await transaction.finish() | ||
| 197 | await refreshEntitlements() | ||
| 198 | } | ||
| 199 | |||
| 200 | private func persistCurrentEntitlement() { | ||
| 201 | let cachedEntitlement = CachedEntitlement( | ||
| 202 | tier: currentTier, | ||
| 203 | activeProductID: activeProductID, | ||
| 204 | updatedAt: Date() | ||
| 205 | ) | ||
| 206 | |||
| 207 | if let data = try? JSONEncoder().encode(cachedEntitlement) { | ||
| 208 | UserDefaults.standard.set(data, forKey: Self.entitlementCacheKey) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | |||
| 212 | private func verifiedTransaction(from result: VerificationResult<Transaction>) throws -> Transaction { | ||
| 213 | switch result { | ||
| 214 | case .verified(let transaction): | ||
| 215 | return transaction | ||
| 216 | case .unverified: | ||
| 217 | throw StoreKitError.notEntitled | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | private func productSortIndex(for productID: String) -> Int { | ||
| 222 | switch productID { | ||
| 223 | case Self.monthlyProductID: | ||
| 224 | return 0 | ||
| 225 | case Self.yearlyProductID: | ||
| 226 | return 1 | ||
| 227 | default: | ||
| 228 | return Int.max | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | private func storeMessage(for error: Error, fallback: String) -> String { | ||
| 233 | if let storeKitError = error as? StoreKitError { | ||
| 234 | switch storeKitError { | ||
| 235 | case .networkError: | ||
| 236 | return "The App Store is offline right now." | ||
| 237 | default: | ||
| 238 | return fallback | ||
| 239 | } | ||
| 240 | } | ||
| 241 | |||
| 242 | let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 243 | return message.isEmpty ? fallback : message | ||
| 244 | } | ||
| 245 | } | ||
DomainDig/RootTabView.swift +25
| @@ -2,8 +2,11 @@ import SwiftUI | |||
| 2 | 2 | ||
| 3 | struct RootTabView: View { | 3 | struct RootTabView: View { |
| 4 | @Bindable var viewModel: DomainViewModel | 4 | @Bindable var viewModel: DomainViewModel |
| 5 | @State private var purchaseService = PurchaseService.shared | ||
| 5 | 6 | ||
| 6 | var body: some View { | 7 | var body: some View { |
| 8 | let _ = purchaseService.currentTier | ||
| 9 | |||
| 7 | TabView { | 10 | TabView { |
| 8 | ContentView(viewModel: viewModel) | 11 | ContentView(viewModel: viewModel) |
| 9 | .tabItem { | 12 | .tabItem { |
| @@ -38,5 +41,27 @@ struct RootTabView: View { | |||
| 38 | Label("Settings", systemImage: "gearshape") | 41 | Label("Settings", systemImage: "gearshape") |
| 39 | } | 42 | } |
| 40 | } | 43 | } |
| 44 | .sheet(isPresented: Binding( | ||
| 45 | get: { viewModel.isPaywallPresented }, | ||
| 46 | set: { viewModel.isPaywallPresented = $0 } | ||
| 47 | )) { | ||
| 48 | PaywallView() | ||
| 49 | } | ||
| 50 | .alert(item: Binding( | ||
| 51 | get: { viewModel.upgradePrompt }, | ||
| 52 | set: { viewModel.upgradePrompt = $0 } | ||
| 53 | )) { prompt in | ||
| 54 | Alert( | ||
| 55 | title: Text(prompt.title), | ||
| 56 | message: Text(prompt.message), | ||
| 57 | primaryButton: .default(Text("Open Paywall")) { | ||
| 58 | viewModel.upgradePrompt = nil | ||
| 59 | viewModel.isPaywallPresented = true | ||
| 60 | }, | ||
| 61 | secondaryButton: .cancel(Text("Continue")) { | ||
| 62 | viewModel.upgradePrompt = nil | ||
| 63 | } | ||
| 64 | ) | ||
| 65 | } | ||
| 41 | } | 66 | } |
| 42 | } | 67 | } |
DomainDig/WatchlistView.swift +3
| @@ -4,6 +4,7 @@ struct WatchlistView: View { | |||
| 4 | @Environment(\.appDensity) private var appDensity | 4 | @Environment(\.appDensity) private var appDensity |
| 5 | @Bindable var viewModel: DomainViewModel | 5 | @Bindable var viewModel: DomainViewModel |
| 6 | @Environment(\.dismiss) private var dismiss | 6 | @Environment(\.dismiss) private var dismiss |
| 7 | @State private var purchaseService = PurchaseService.shared | ||
| 7 | @State private var showWorkflowAddSheet = false | 8 | @State private var showWorkflowAddSheet = false |
| 8 | 9 | ||
| 9 | private var pinnedDomains: [TrackedDomain] { | 10 | private var pinnedDomains: [TrackedDomain] { |
| @@ -15,6 +16,8 @@ struct WatchlistView: View { | |||
| 15 | } | 16 | } |
| 16 | 17 | ||
| 17 | var body: some View { | 18 | var body: some View { |
| 19 | let _ = purchaseService.currentTier | ||
| 20 | |||
| 18 | List { | 21 | List { |
| 19 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { | 22 | if viewModel.batchLookupSource == .watchlistRefresh, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { |
| 20 | Section("Refresh Progress") { | 23 | Section("Refresh Progress") { |
DomainDig/WorkflowsView.swift +28 −25
| @@ -10,24 +10,24 @@ private enum WorkflowDestinationMode: String, CaseIterable, Identifiable { | |||
| 10 | struct WorkflowsView: View { | 10 | struct WorkflowsView: View { |
| 11 | @Environment(\.appDensity) private var appDensity | 11 | @Environment(\.appDensity) private var appDensity |
| 12 | @Bindable var viewModel: DomainViewModel | 12 | @Bindable var viewModel: DomainViewModel |
| 13 | @State private var purchaseService = PurchaseService.shared | ||
| 13 | 14 | ||
| 14 | @State private var showingCreateWorkflow = false | 15 | @State private var showingCreateWorkflow = false |
| 15 | 16 | ||
| 16 | var body: some View { | 17 | var body: some View { |
| 18 | let _ = purchaseService.currentTier | ||
| 19 | |||
| 17 | List { | 20 | List { |
| 18 | if FeatureAccessService.hasAccess(to: .workflows) { | 21 | if let limitMessage = FeatureAccessService.workflowAllowanceSummary(currentCount: viewModel.workflows.count) { |
| 19 | workflowContent | ||
| 20 | } else { | ||
| 21 | Section { | 22 | Section { |
| 22 | EmptyStateCardView( | 23 | Text(limitMessage) |
| 23 | title: "Workflows", | 24 | .font(appDensity.font(.caption)) |
| 24 | message: "Reusable workflow sets are scaffolded for this release and will unlock with Pro.", | 25 | .foregroundStyle(.secondary) |
| 25 | suggestion: FeatureAccessService.upgradeMessage(for: .workflows), | ||
| 26 | systemImage: "square.stack.3d.down.right" | ||
| 27 | ) | ||
| 28 | } | 26 | } |
| 29 | .listRowBackground(Color(.systemGray6).opacity(0.5)) | 27 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| 30 | } | 28 | } |
| 29 | |||
| 30 | workflowContent | ||
| 31 | } | 31 | } |
| 32 | .scrollContentBackground(.hidden) | 32 | .scrollContentBackground(.hidden) |
| 33 | .background(Color.black) | 33 | .background(Color.black) |
| @@ -36,17 +36,15 @@ struct WorkflowsView: View { | |||
| 36 | } | 36 | } |
| 37 | .navigationTitle("Workflows") | 37 | .navigationTitle("Workflows") |
| 38 | .toolbar { | 38 | .toolbar { |
| 39 | if FeatureAccessService.hasAccess(to: .workflows) { | 39 | ToolbarItemGroup(placement: .topBarTrailing) { |
| 40 | ToolbarItemGroup(placement: .topBarTrailing) { | 40 | Button { |
| 41 | Button { | 41 | showingCreateWorkflow = true |
| 42 | showingCreateWorkflow = true | 42 | } label: { |
| 43 | } label: { | 43 | Image(systemName: "plus.circle") |
| 44 | Image(systemName: "plus.circle") | 44 | } |
| 45 | } | ||
| 46 | 45 | ||
| 47 | if !viewModel.workflows.isEmpty { | 46 | if !viewModel.workflows.isEmpty { |
| 48 | EditButton() | 47 | EditButton() |
| 49 | } | ||
| 50 | } | 48 | } |
| 51 | } | 49 | } |
| 52 | } | 50 | } |
| @@ -429,10 +427,13 @@ struct WorkflowComposerView: View { | |||
| 429 | private func saveWorkflow() { | 427 | private func saveWorkflow() { |
| 430 | if let workflow { | 428 | if let workflow { |
| 431 | viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes) | 429 | viewModel.updateWorkflow(workflow, name: name, domains: parsedDomains, notes: notes) |
| 432 | } else { | 430 | dismiss() |
| 433 | _ = viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) | 431 | return |
| 432 | } | ||
| 433 | |||
| 434 | if viewModel.createWorkflow(name: name, domains: parsedDomains, notes: notes) != nil { | ||
| 435 | dismiss() | ||
| 434 | } | 436 | } |
| 435 | dismiss() | ||
| 436 | } | 437 | } |
| 437 | } | 438 | } |
| 438 | 439 | ||
| @@ -678,10 +679,12 @@ struct WorkflowBulkAddSheet: View { | |||
| 678 | case .existing: | 679 | case .existing: |
| 679 | guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return } | 680 | guard let selectedWorkflowID, let workflow = viewModel.workflow(withID: selectedWorkflowID) else { return } |
| 680 | viewModel.addDomains(selectedDomainList, to: workflow) | 681 | viewModel.addDomains(selectedDomainList, to: workflow) |
| 682 | dismiss() | ||
| 681 | case .new: | 683 | case .new: |
| 682 | _ = viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) | 684 | guard viewModel.createWorkflow(name: newWorkflowName, domains: selectedDomainList, notes: newWorkflowNotes) != nil else { |
| 685 | return | ||
| 686 | } | ||
| 687 | dismiss() | ||
| 683 | } | 688 | } |
| 684 | |||
| 685 | dismiss() | ||
| 686 | } | 689 | } |
| 687 | } | 690 | } |