diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/AppVersion.swift | 2 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 225 | ||||
| -rw-r--r-- | DomainDig/DataAccessService.swift | 11 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 48 | ||||
| -rw-r--r-- | DomainDig/FeatureAccessService.swift | 160 | ||||
| -rw-r--r-- | DomainDig/PremiumAccessService.swift | 15 | ||||
| -rw-r--r-- | DomainDig/RootTabView.swift | 42 | ||||
| -rw-r--r-- | DomainDig/WatchlistView.swift | 19 | ||||
| -rw-r--r-- | DomainDig/WorkflowsView.swift | 159 |
10 files changed, 524 insertions, 160 deletions
diff --git a/DomainDig/AppVersion.swift b/DomainDig/AppVersion.swift index 9342324..1a6861a 100644 --- a/DomainDig/AppVersion.swift +++ b/DomainDig/AppVersion.swift @@ -2,6 +2,6 @@ import Foundation enum AppVersion { static var current: String { - "2.9.0" + "3.0.0" } } diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index 79ecf64..9802f77 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -24,7 +24,7 @@ private struct WorkflowNavigationTarget: Hashable { struct ContentView: View { @Environment(\.appDensity) private var appDensity - @State private var viewModel = DomainViewModel() + @Bindable var viewModel: DomainViewModel @State private var navigationPath = NavigationPath() @FocusState private var domainFieldFocused: Bool @State private var customPortInput = "" @@ -32,6 +32,7 @@ struct ContentView: View { @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 @@ -222,7 +223,7 @@ struct ContentView: View { .toolbarColorScheme(.dark, for: .navigationBar) .preferredColorScheme(.dark) .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { + ToolbarItem(placement: .topBarTrailing) { if viewModel.hasRun { Button { viewModel.reset() @@ -231,40 +232,6 @@ struct ContentView: View { .foregroundStyle(.secondary) } } - NavigationLink { - WatchlistView(viewModel: viewModel) - } label: { - Image(systemName: "eye") - .foregroundStyle(.secondary) - } - Menu { - NavigationLink { - HistoryView(viewModel: viewModel) - } label: { - Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") - } - - NavigationLink { - SavedDomainsView(viewModel: viewModel) - } label: { - Label("Saved Domains", systemImage: "bookmark") - } - - NavigationLink { - WorkflowsView(viewModel: viewModel) - } label: { - Label("Workflows", systemImage: "square.stack.3d.down.right") - } - - NavigationLink { - SettingsView(viewModel: viewModel) - } label: { - Label("Settings", systemImage: "gearshape") - } - } label: { - Image(systemName: "ellipsis.circle") - .foregroundStyle(.secondary) - } } } .navigationDestination(for: WorkflowNavigationTarget.self) { target in @@ -284,7 +251,19 @@ struct ContentView: View { .alert("Tracking limit reached", isPresented: $showTrackLimitAlert) { Button("OK", role: .cancel) {} } message: { - Text("Free version supports up to 3 tracked domains. More tracked domains will be available in a future Pro upgrade.") + 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 { @@ -361,37 +340,44 @@ struct ContentView: View { .buttonStyle(.borderedProminent) .disabled(viewModel.trimmedDomain.isEmpty) } else { - VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { - Text("Paste domains separated by new lines or commas.") - .font(appDensity.font(.caption)) - .foregroundStyle(.secondary) + if FeatureAccessService.hasAccess(to: .batchOperations) { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + Text("Paste domains separated by new lines or commas.") + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) - TextField( - "example.com\napple.com, openai.com", - text: $viewModel.bulkInput, - axis: .vertical - ) - .font(appDensity.font(.body)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - .lineLimit(4...10) - .padding(.horizontal, 12) - .padding(.vertical, appDensity.metrics.controlVerticalPadding) - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + TextField( + "example.com\napple.com, openai.com", + text: $viewModel.bulkInput, + axis: .vertical + ) + .font(appDensity.font(.body)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .lineLimit(4...10) + .padding(.horizontal, 12) + .padding(.vertical, appDensity.metrics.controlVerticalPadding) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) - Button { - domainFieldFocused = false - viewModel.runBulkLookup() - } label: { - Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") - .font(appDensity.font(.headline, design: .default, weight: .semibold)) - .frame(maxWidth: .infinity) - .frame(minHeight: appDensity.metrics.controlMinHeight) + Button { + domainFieldFocused = false + viewModel.runBulkLookup() + } label: { + Text(viewModel.batchLookupRunning ? "Running Batch…" : "Run Batch") + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(minHeight: appDensity.metrics.controlMinHeight) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) } - .buttonStyle(.borderedProminent) - .disabled(viewModel.bulkInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.batchLookupRunning) + } else { + lockedFeatureCard( + title: "Batch Operations", + message: FeatureAccessService.upgradeMessage(for: .batchOperations) + ) } } } @@ -411,7 +397,11 @@ struct ContentView: View { } } Button("Add to workflow") { - showingCurrentDomainWorkflowSheet = true + if FeatureAccessService.hasAccess(to: .workflows) { + showingCurrentDomainWorkflowSheet = true + } else { + featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) + } } Button("Copy report JSON") { guard let json = viewModel.exportJSONString() else { return } @@ -437,11 +427,18 @@ struct ContentView: View { Button("Export TXT") { shareSingleResults(format: .text) } - Button("Export CSV") { - shareSingleResults(format: .csv) - } - Button("Export JSON") { - shareSingleResults(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + shareSingleResults(format: .csv) + } + Button("Export JSON") { + shareSingleResults(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Image(systemName: "square.and.arrow.up") @@ -466,17 +463,28 @@ struct ContentView: View { if !viewModel.currentBatchResultEntries.isEmpty { Menu { Button("Add to Workflow") { - showingBatchWorkflowSheet = true + if FeatureAccessService.hasAccess(to: .workflows) { + showingBatchWorkflowSheet = true + } else { + featureGateMessage = FeatureAccessService.upgradeMessage(for: .workflows) + } } Divider() Button("Export Batch TXT") { shareBatchResults(format: .text) } - Button("Export Batch CSV") { - shareBatchResults(format: .csv) - } - Button("Export Batch JSON") { - shareBatchResults(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export Batch CSV") { + shareBatchResults(format: .csv) + } + Button("Export Batch JSON") { + shareBatchResults(format: .json) + } + } else { + Button("Batch CSV • Available in Pro") {} + .disabled(true) + Button("Batch JSON • Available in Pro") {} + .disabled(true) } } label: { Label("Export", systemImage: "square.and.arrow.up") @@ -534,6 +542,20 @@ struct ContentView: View { } } + private func lockedFeatureCard(title: String, message: String) -> some View { + VStack(alignment: .leading, spacing: appDensity.metrics.cardSpacing) { + Text(title) + .font(appDensity.font(.headline, design: .default, weight: .semibold)) + Text(message) + .font(appDensity.font(.callout, design: .default)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(appDensity.metrics.cardPadding) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: appDensity.metrics.cardCornerRadius)) + } + private func parsedCustomPorts(from input: String) -> [UInt16] { let parts = input.split(separator: ",", omittingEmptySubsequences: true) var seen = Set<UInt16>() @@ -2140,7 +2162,7 @@ private extension String { } } -private struct SettingsView: View { +struct SettingsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel @AppStorage(DNSResolverOption.userDefaultsKey) @@ -2152,6 +2174,8 @@ private struct SettingsView: View { @State private var customResolverURL = DNSResolverOption.defaultURLString @State private var showClearHistoryConfirmation = false @State private var showClearCacheConfirmation = false + @State private var showClearWorkflowsConfirmation = false + @State private var showClearTrackedDomainsConfirmation = false private var customResolverError: String? { guard resolverOption == .custom else { @@ -2199,11 +2223,38 @@ private struct SettingsView: View { Button("Clear Cache", role: .destructive) { showClearCacheConfirmation = true } + + Button("Clear Workflows", role: .destructive) { + showClearWorkflowsConfirmation = true + } + + Button("Clear Tracked Domains", role: .destructive) { + showClearTrackedDomainsConfirmation = true + } + } + + Section("Features") { + LabeledContent("Tier", value: FeatureAccessService.currentTier.title) + + if FeatureAccessService.enabledFeatureLabels().isEmpty { + Text("No features enabled.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } else { + ForEach(FeatureAccessService.enabledFeatureLabels(), id: \.self) { label in + Text(label) + } + } + + 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) } Section("About") { LabeledContent("Version", value: appVersion) LabeledContent("Storage", value: "Local-only") + LabeledContent("Report Schema", value: "3.0.0") } } .navigationTitle("Settings") @@ -2223,6 +2274,22 @@ private struct SettingsView: View { } message: { Text("This clears the in-memory lookup cache and cancels any cached in-flight work.") } + .alert("Clear workflows?", isPresented: $showClearWorkflowsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearWorkflows() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes saved workflows only. History, tracked domains, and saved reports stay intact.") + } + .alert("Clear tracked domains?", isPresented: $showClearTrackedDomainsConfirmation) { + Button("Clear", role: .destructive) { + viewModel.clearTrackedDomains() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This removes the watchlist only. History and workflows stay intact.") + } .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) @@ -2247,5 +2314,5 @@ private struct SettingsView: View { } #Preview { - ContentView() + ContentView(viewModel: DomainViewModel()) } diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift index fed4fde..d0254ac 100644 --- a/DomainDig/DataAccessService.swift +++ b/DomainDig/DataAccessService.swift @@ -2,6 +2,15 @@ import Foundation enum DataAccessService { static func hasAccess(to capability: DataCapability) -> Bool { - false + switch capability { + case .ownershipHistory: + return FeatureAccessService.hasAccess(to: .ownershipHistory) + case .dnsHistory: + return FeatureAccessService.hasAccess(to: .dnsHistory) + case .extendedSubdomains: + return FeatureAccessService.hasAccess(to: .extendedSubdomains) + case .domainPricing: + return false + } } } diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index 29e0117..5eeed32 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -10,6 +10,7 @@ import SwiftUI @main struct DomainDigApp: App { @AppStorage(AppDensity.userDefaultsKey) private var density = AppDensity.compact.rawValue + @State private var viewModel = DomainViewModel() init() { LocalNotificationService.shared.configureForegroundPresentation() @@ -17,7 +18,7 @@ struct DomainDigApp: App { var body: some Scene { WindowGroup { - ContentView() + RootTabView(viewModel: viewModel) .environment(\.appDensity, AppDensity(rawValue: density) ?? .compact) } } diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 18ce4c2..5288eaa 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -369,11 +369,11 @@ final class DomainViewModel { } var trackingLimitMessage: String? { - nil + FeatureAccessService.trackedDomainLimitMessage(currentCount: trackedDomains.count) } var canTrackCurrentDomain: Bool { - true + FeatureAccessService.canAddTrackedDomain(currentCount: trackedDomains.count) } var resolverDisplayName: String { @@ -665,6 +665,18 @@ final class DomainViewModel { } } + func clearWorkflows() { + workflows.removeAll() + latestWorkflowRunSummary = nil + persistWorkflows() + } + + func clearTrackedDomains() { + trackedDomains.removeAll() + refreshingTrackedDomainID = nil + persistTrackedDomains() + } + func clearRecentSearches() { recentSearches.removeAll() UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) @@ -725,10 +737,12 @@ final class DomainViewModel { func runBulkLookup() { let domains = parsedDomains(from: bulkInput) guard !domains.isEmpty else { return } + guard FeatureAccessService.canRunBatch(domainCount: domains.count) else { return } startBatchLookup(domains: domains, source: .manual) } func refreshAllTrackedDomains() { + guard FeatureAccessService.canRunBatch(domainCount: sortedTrackedDomains.count) else { return } startBatchLookup(domains: sortedTrackedDomains.map(\.domain), source: .watchlistRefresh) } @@ -777,6 +791,7 @@ 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 } let workflow = DomainWorkflow( name: trimmedName, @@ -840,6 +855,7 @@ final class DomainViewModel { func runWorkflow(_ workflow: DomainWorkflow) { guard !workflow.domains.isEmpty else { return } + guard FeatureAccessService.canRunBatch(domainCount: workflow.domains.count) else { return } startBatchLookup(domains: workflow.domains, source: .workflow, workflow: workflow) } @@ -2124,14 +2140,23 @@ final class DomainViewModel { } private func currentBatchReports() -> [DomainReport] { - currentBatchResultEntries.map(report(for:)) + currentBatchResultEntries.map { entry in + report(for: entry, workflowContext: activeWorkflowContext) + } } private func workflowReports(from summary: WorkflowRunSummary, changedOnly: Bool) -> [DomainReport] { let filteredResults = changedOnly ? summary.results.filter(\.hasMeaningfulChange) : summary.results return filteredResults.compactMap { result in guard let entry = historyEntry(for: result) else { return nil } - return report(for: entry) + return report( + for: entry, + workflowContext: DomainWorkflowContext( + workflowID: summary.workflowID, + workflowName: summary.workflowName, + source: "workflow" + ) + ) } } @@ -2150,8 +2175,19 @@ final class DomainViewModel { } } - private func report(for entry: HistoryEntry) -> DomainReport { - reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry)) + private func report(for entry: HistoryEntry, workflowContext: DomainWorkflowContext? = nil) -> DomainReport { + reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry), workflowContext: workflowContext) + } + + private var activeWorkflowContext: DomainWorkflowContext? { + guard batchLookupSource == .workflow, let activeWorkflowRunID, let activeWorkflowRunName else { + return nil + } + return DomainWorkflowContext( + workflowID: activeWorkflowRunID, + workflowName: activeWorkflowRunName, + source: "workflow" + ) } private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { diff --git a/DomainDig/FeatureAccessService.swift b/DomainDig/FeatureAccessService.swift new file mode 100644 index 0000000..5f380bb --- /dev/null +++ b/DomainDig/FeatureAccessService.swift @@ -0,0 +1,160 @@ +import Foundation + +enum FeatureTier: String, Codable, CaseIterable, Identifiable { + case free + case pro + case dataPlus + + var id: String { rawValue } + + var title: String { + switch self { + case .free: + return "Free" + case .pro: + return "Pro" + case .dataPlus: + return "Data+" + } + } +} + +enum FeatureCapability: String, CaseIterable, Identifiable { + case singleLookup + case basicHistory + case limitedTracking + case workflows + case batchOperations + case advancedExports + case ownershipHistory + case dnsHistory + case extendedSubdomains + + var id: String { rawValue } + + var title: String { + switch self { + case .singleLookup: + return "Single lookup" + case .basicHistory: + return "Basic history" + case .limitedTracking: + return "Limited tracking" + case .workflows: + return "Workflows" + case .batchOperations: + return "Batch operations" + case .advancedExports: + return "Advanced exports" + case .ownershipHistory: + return "Ownership history" + case .dnsHistory: + return "DNS history" + case .extendedSubdomains: + return "Extended subdomains" + } + } +} + +struct FeatureEntitlements: Equatable { + let tier: FeatureTier + let capabilities: Set<FeatureCapability> + let trackedDomainLimit: Int + let workflowLimit: Int? + let batchSizeLimit: Int? +} + +enum FeatureAccessService { + static let currentTier: FeatureTier = .free + + static var entitlements: FeatureEntitlements { + switch currentTier { + case .free: + return FeatureEntitlements( + tier: .free, + capabilities: [.singleLookup, .basicHistory, .limitedTracking], + trackedDomainLimit: 3, + workflowLimit: 0, + batchSizeLimit: 0 + ) + case .pro: + return FeatureEntitlements( + tier: .pro, + capabilities: [.singleLookup, .basicHistory, .limitedTracking, .workflows, .batchOperations, .advancedExports], + trackedDomainLimit: 250, + workflowLimit: 50, + batchSizeLimit: 100 + ) + case .dataPlus: + return FeatureEntitlements( + tier: .dataPlus, + capabilities: Set(FeatureCapability.allCases), + trackedDomainLimit: 1_000, + workflowLimit: 200, + batchSizeLimit: 250 + ) + } + } + + static func hasAccess(to capability: FeatureCapability) -> Bool { + entitlements.capabilities.contains(capability) + } + + static func canAddTrackedDomain(currentCount: Int) -> Bool { + currentCount < entitlements.trackedDomainLimit + } + + 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." + } + + static func canCreateWorkflow(currentCount: Int) -> Bool { + guard hasAccess(to: .workflows) else { return false } + guard let limit = entitlements.workflowLimit else { return true } + return currentCount < limit + } + + static func canRunBatch(domainCount: Int) -> Bool { + guard hasAccess(to: .batchOperations) else { return false } + guard let limit = entitlements.batchSizeLimit else { return true } + return domainCount <= limit + } + + static func upgradeMessage(for capability: FeatureCapability) -> String { + switch capability { + case .workflows, .batchOperations, .advancedExports: + return "Available in Pro" + case .ownershipHistory, .dnsHistory, .extendedSubdomains: + return "Available in Data+" + case .limitedTracking: + return "Tracking is limited on Free" + case .singleLookup, .basicHistory: + return "Included in Free" + } + } + + 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." + } + + 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 "Batch limit is \(limit) domains on \(currentTier.title)." + } + + static func enabledFeatureLabels() -> [String] { + FeatureCapability.allCases + .filter { hasAccess(to: $0) } + .map(\.title) + } +} diff --git a/DomainDig/PremiumAccessService.swift b/DomainDig/PremiumAccessService.swift index 0987dae..8de2626 100644 --- a/DomainDig/PremiumAccessService.swift +++ b/DomainDig/PremiumAccessService.swift @@ -2,14 +2,23 @@ import Foundation enum PremiumAccessService { static func hasAccess(to capability: PremiumCapability) -> Bool { - true + switch capability { + case .advancedExports: + return FeatureAccessService.hasAccess(to: .advancedExports) + case .batchTracking: + return FeatureAccessService.hasAccess(to: .batchOperations) + case .unlimitedTrackedDomains: + return FeatureAccessService.currentTier != .free + case .automatedMonitoring, .pushAlerts: + return false + } } static func trackedDomainLimitMessage(currentCount: Int) -> String? { - nil + FeatureAccessService.trackedDomainLimitMessage(currentCount: currentCount) } static func canAddTrackedDomain(currentCount: Int) -> Bool { - true + FeatureAccessService.canAddTrackedDomain(currentCount: currentCount) } } diff --git a/DomainDig/RootTabView.swift b/DomainDig/RootTabView.swift new file mode 100644 index 0000000..19ea444 --- /dev/null +++ b/DomainDig/RootTabView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct RootTabView: View { + @Bindable var viewModel: DomainViewModel + + var body: some View { + TabView { + ContentView(viewModel: viewModel) + .tabItem { + Label("Inspect", systemImage: "magnifyingglass") + } + + NavigationStack { + WatchlistView(viewModel: viewModel) + } + .tabItem { + Label("Watchlist", systemImage: "eye") + } + + NavigationStack { + HistoryView(viewModel: viewModel) + } + .tabItem { + Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + NavigationStack { + WorkflowsView(viewModel: viewModel) + } + .tabItem { + Label("Workflows", systemImage: "square.stack.3d.down.right") + } + + NavigationStack { + SettingsView(viewModel: viewModel) + } + .tabItem { + Label("Settings", systemImage: "gearshape") + } + } + } +} diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift index a434705..fcb5a16 100644 --- a/DomainDig/WatchlistView.swift +++ b/DomainDig/WatchlistView.swift @@ -55,7 +55,7 @@ struct WatchlistView: View { } .listRowBackground(Color(.systemGray6).opacity(0.5)) } else { - if let limitMessage = PremiumAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { + if let limitMessage = FeatureAccessService.trackedDomainLimitMessage(currentCount: viewModel.trackedDomains.count) { Section { Text(limitMessage) .font(appDensity.font(.caption)) @@ -108,12 +108,19 @@ struct WatchlistView: View { shareTrackedDomains(format: .text) } - Button("Export CSV") { - shareTrackedDomains(format: .csv) - } + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + shareTrackedDomains(format: .csv) + } - Button("Export JSON") { - shareTrackedDomains(format: .json) + Button("Export JSON") { + shareTrackedDomains(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Image(systemName: "line.3.horizontal.decrease.circle") diff --git a/DomainDig/WorkflowsView.swift b/DomainDig/WorkflowsView.swift index b870409..9d99b02 100644 --- a/DomainDig/WorkflowsView.swift +++ b/DomainDig/WorkflowsView.swift @@ -15,57 +15,18 @@ struct WorkflowsView: View { var body: some View { List { - if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { - Section("Workflow Run") { - VStack(alignment: .leading, spacing: 8) { - ProgressView( - value: Double(viewModel.batchCompletedCount), - total: Double(max(viewModel.batchTotalCount, 1)) - ) - .tint(.cyan) - - HStack { - Text(viewModel.batchProgressLabel) - .font(appDensity.font(.caption)) - .foregroundStyle(.secondary) - Spacer() - if viewModel.batchLookupRunning { - Button("Cancel") { - viewModel.cancelBatchLookup() - } - .buttonStyle(.bordered) - .font(appDensity.font(.caption2)) - } - } - } - .padding(.vertical, 4) - } - .listRowBackground(Color(.systemGray6).opacity(0.5)) - } - - if viewModel.workflows.isEmpty { + if FeatureAccessService.hasAccess(to: .workflows) { + workflowContent + } else { Section { EmptyStateCardView( - title: "No Workflows Yet", - message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.", - suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.", + 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" ) } .listRowBackground(Color(.systemGray6).opacity(0.5)) - } else { - ForEach(viewModel.workflows) { workflow in - NavigationLink { - WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id) - } label: { - WorkflowRowView(workflow: workflow) - } - .listRowBackground(Color(.systemGray6).opacity(0.5)) - } - .onDelete { offsets in - let workflows = offsets.map { viewModel.workflows[$0] } - workflows.forEach(viewModel.deleteWorkflow) - } } } .scrollContentBackground(.hidden) @@ -75,15 +36,17 @@ struct WorkflowsView: View { } .navigationTitle("Workflows") .toolbar { - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - showingCreateWorkflow = true - } label: { - Image(systemName: "plus.circle") - } + if FeatureAccessService.hasAccess(to: .workflows) { + ToolbarItemGroup(placement: .topBarTrailing) { + Button { + showingCreateWorkflow = true + } label: { + Image(systemName: "plus.circle") + } - if !viewModel.workflows.isEmpty { - EditButton() + if !viewModel.workflows.isEmpty { + EditButton() + } } } } @@ -102,6 +65,62 @@ struct WorkflowsView: View { set: { viewModel.latestWorkflowRunSummary = $0 } ) } + + @ViewBuilder + private var workflowContent: some View { + if viewModel.batchLookupSource == .workflow, (!viewModel.batchResults.isEmpty || viewModel.batchLookupRunning) { + Section("Workflow Run") { + VStack(alignment: .leading, spacing: 8) { + ProgressView( + value: Double(viewModel.batchCompletedCount), + total: Double(max(viewModel.batchTotalCount, 1)) + ) + .tint(.cyan) + + HStack { + Text(viewModel.batchProgressLabel) + .font(appDensity.font(.caption)) + .foregroundStyle(.secondary) + Spacer() + if viewModel.batchLookupRunning { + Button("Cancel") { + viewModel.cancelBatchLookup() + } + .buttonStyle(.bordered) + .font(appDensity.font(.caption2)) + } + } + } + .padding(.vertical, 4) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + + if viewModel.workflows.isEmpty { + Section { + EmptyStateCardView( + title: "No Workflows Yet", + message: "Workflows save a reusable set of domains so repeat inspections take one tap instead of rebuilding the same batch each time.", + suggestion: "Create a workflow for a weekly audit set, customer domains, or a monitoring group.", + systemImage: "square.stack.3d.down.right" + ) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + ForEach(viewModel.workflows) { workflow in + NavigationLink { + WorkflowDetailView(viewModel: viewModel, workflowID: workflow.id) + } label: { + WorkflowRowView(workflow: workflow) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete { offsets in + let workflows = offsets.map { viewModel.workflows[$0] } + workflows.forEach(viewModel.deleteWorkflow) + } + } + } } private struct WorkflowRowView: View { @@ -197,11 +216,18 @@ struct WorkflowDetailView: View { Button("Export TXT") { shareWorkflowResults(format: .text) } - Button("Export CSV") { - shareWorkflowResults(format: .csv) - } - Button("Export JSON") { - shareWorkflowResults(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + shareWorkflowResults(format: .csv) + } + Button("Export JSON") { + shareWorkflowResults(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Label("Export Results", systemImage: "square.and.arrow.up") @@ -478,11 +504,18 @@ struct WorkflowRunSummaryView: View { Button("Export TXT") { share(format: .text) } - Button("Export CSV") { - share(format: .csv) - } - Button("Export JSON") { - share(format: .json) + if FeatureAccessService.hasAccess(to: .advancedExports) { + Button("Export CSV") { + share(format: .csv) + } + Button("Export JSON") { + share(format: .json) + } + } else { + Button("CSV Export • Available in Pro") {} + .disabled(true) + Button("JSON Export • Available in Pro") {} + .disabled(true) } } label: { Image(systemName: "square.and.arrow.up") |
