summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/AppVersion.swift2
-rw-r--r--DomainDig/ContentView.swift225
-rw-r--r--DomainDig/DataAccessService.swift11
-rw-r--r--DomainDig/DomainDigApp.swift3
-rw-r--r--DomainDig/DomainViewModel.swift48
-rw-r--r--DomainDig/FeatureAccessService.swift160
-rw-r--r--DomainDig/PremiumAccessService.swift15
-rw-r--r--DomainDig/RootTabView.swift42
-rw-r--r--DomainDig/WatchlistView.swift19
-rw-r--r--DomainDig/WorkflowsView.swift159
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")