diff --git a/overpass/Services/StoreManager.swift b/overpass/Services/StoreManager.swift new file mode 100644 index 0000000..1f68480 --- /dev/null +++ b/overpass/Services/StoreManager.swift @@ -0,0 +1,98 @@ +import Foundation +import Observation +import StoreKit + +@MainActor +@Observable +final class StoreManager { + static let shared = StoreManager() + + private(set) var isUnlocked = false + private(set) var purchaseDate: Date? + private(set) var product: Product? + private(set) var isPurchasing = false + + private let productId = "sh.dunkirk.overpass.unlock" + private let firstLaunchKeychainKey = "sh.dunkirk.overpass.first_launch" + static let trialDuration: TimeInterval = 7 * 24 * 60 * 60 + + var trialStartDate: Date? { + guard let stored = KeychainService.load(forKey: firstLaunchKeychainKey), + let t = Double(stored) else { return nil } + return Date(timeIntervalSince1970: t) + } + + var daysRemainingInTrial: Int { + guard let start = trialStartDate else { return 0 } + let remaining = Self.trialDuration - Date().timeIntervalSince(start) + return max(0, Int(ceil(remaining / 86400))) + } + + var isInTrial: Bool { + guard !isUnlocked, let start = trialStartDate else { return false } + return Date().timeIntervalSince(start) < Self.trialDuration + } + + var hasAccess: Bool { isUnlocked || isInTrial } + + private var updatesTask: Task? + + private init() { + if KeychainService.load(forKey: firstLaunchKeychainKey) == nil { + KeychainService.save("\(Date().timeIntervalSince1970)", forKey: firstLaunchKeychainKey) + } + } + + func load() async { + updatesTask = Task { await listenForUpdates() } + async let p: () = loadProduct() + async let e: () = checkEntitlements() + _ = await (p, e) + } + + private func listenForUpdates() async { + for await result in Transaction.updates { + if case .verified(let tx) = result, tx.productID == productId { + if tx.revocationDate != nil { + isUnlocked = false + purchaseDate = nil + } else { + isUnlocked = true + purchaseDate = tx.purchaseDate + } + await tx.finish() + } + } + } + + private func loadProduct() async { + product = try? await Product.products(for: [productId]).first + } + + private func checkEntitlements() async { + for await result in Transaction.currentEntitlements { + if case .verified(let tx) = result, tx.productID == productId { + isUnlocked = true + purchaseDate = tx.purchaseDate + await tx.finish() + return + } + } + } + + func purchase() async throws { + guard let product else { return } + isPurchasing = true + defer { isPurchasing = false } + let result = try await product.purchase() + if case .success(let verification) = result, + case .verified(_) = verification { + isUnlocked = true + } + } + + func restore() async { + try? await AppStore.sync() + await checkEntitlements() + } +} diff --git a/overpass/StoreKit Config.storekit b/overpass/StoreKit Config.storekit new file mode 100644 index 0000000..0a60155 --- /dev/null +++ b/overpass/StoreKit Config.storekit @@ -0,0 +1,54 @@ +{ + "appPolicies" : { + "eula" : "", + "policies" : [ + { + "locale" : "en_US", + "policyText" : "", + "policyURL" : "" + } + ] + }, + "identifier" : "42CF0912", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "3.99", + "familyShareable" : true, + "internalID" : "8BB50709", + "localizations" : [ + { + "description" : "", + "displayName" : "", + "locale" : "en_US" + } + ], + "productID" : "sh.dunkirk.overpass.unlock", + "referenceName" : "Purchase Overpass", + "type" : "NonConsumable" + } + ], + "settings" : { + "_askToBuyEnabled" : true, + "_billingGracePeriodEnabled" : false, + "_billingIssuesEnabled" : false, + "_disableDialogs" : false, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_renewalBillingIssuesEnabled" : false, + "_storefront" : "USA", + "_storeKitErrors" : [ + + ], + "_timeRate" : 0 + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 5, + "minor" : 0 + } +} diff --git a/overpass/Views/PaywallView.swift b/overpass/Views/PaywallView.swift new file mode 100644 index 0000000..fd0b9df --- /dev/null +++ b/overpass/Views/PaywallView.swift @@ -0,0 +1,115 @@ +import SwiftUI +import StoreKit + +struct PaywallView: View { + private let store = StoreManager.shared + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 16) { + Image(systemName: "fuelpump.fill") + .font(.system(size: 64)) + .foregroundStyle(.orange) + + VStack(spacing: 6) { + Text("Overpass") + .font(.largeTitle.bold()) + + Text("Find the cheapest gas near you.") + .font(.title3) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + + Spacer() + + VStack(spacing: 12) { + FeatureRow(icon: "dollarsign.circle.fill", color: .green, + title: "Real-time prices", subtitle: "Always up-to-date gas prices nearby") + FeatureRow(icon: "arrow.trianglehead.2.clockwise.rotate.90.circle.fill", color: .blue, + title: "Route pre-caching", subtitle: "Prefetch stations along any route") + FeatureRow(icon: "mic.circle.fill", color: .purple, + title: "Siri shortcuts", subtitle: "Find gas hands-free") + FeatureRow(icon: "arrow.clockwise.circle.fill", color: .orange, + title: "Background refresh", subtitle: "Prices stay fresh automatically") + } + .padding(.horizontal) + + Spacer() + + if store.isInTrial { + let days = store.daysRemainingInTrial + Text("\(days) day\(days == 1 ? "" : "s") left in your free trial") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.orange) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12)) + .padding(.bottom, 16) + } + + VStack(spacing: 12) { + Button { + Task { try? await store.purchase() } + } label: { + Group { + if store.isPurchasing { + ProgressView() + .tint(.white) + } else { + Text("Get Overpass — \(store.product?.displayPrice ?? "$2.99")") + .font(.headline) + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + .disabled(store.isPurchasing) + + Button("Restore Purchase") { + Task { await store.restore() } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + + Text("One-time purchase · No subscription") + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.top, 12) + .padding(.bottom, 32) + } + } +} + +private struct FeatureRow: View { + let icon: String + let color: Color + let title: String + let subtitle: String + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(color) + .frame(width: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + } + } +} diff --git a/overpass/Views/SettingsView.swift b/overpass/Views/SettingsView.swift index 67ae897..69a7dfb 100644 --- a/overpass/Views/SettingsView.swift +++ b/overpass/Views/SettingsView.swift @@ -1,3 +1,4 @@ +import StoreKit import SwiftUI struct SettingsView: View { @@ -5,40 +6,21 @@ struct SettingsView: View { @EnvironmentObject private var store: StationStore @AppStorage("balance_mpg") private var balanceMpg: Double = 28 @AppStorage("balance_tank") private var balanceTank: Double = 12 - @State private var baseURL: String = "" - @State private var deviceSecret: String = "" - @State private var registrationError: String? - @State private var isRegistering = false - @State private var health: HealthResponse? - @State private var isLoadingHealth = false - @State private var hasApiKey = false @State private var showClearConfirm = false - - private static let iso8601 = ISO8601DateFormatter() + @State private var devTapCount = 0 + @State private var showDevSettings = false + private let storeManager = StoreManager.shared + @State private var isRestoring = false + @State private var restoreSucceeded: Bool? = nil var body: some View { NavigationStack { Form { bestValueSection cacheSection - authSection - versionSection + aboutSection } .navigationTitle("Settings") - .onAppear { - baseURL = api.baseURL - deviceSecret = KeychainService.load(forKey: "device_secret") ?? "" - hasApiKey = KeychainService.load(forKey: "user_api_key") != nil - Task { await loadHealth() } - } - .onChange(of: baseURL) { _, new in api.baseURL = new } - .onChange(of: deviceSecret) { _, new in - if new.isEmpty { - KeychainService.delete(forKey: "device_secret") - } else { - KeychainService.save(new, forKey: "device_secret") - } - } .confirmationDialog( "Clear on-device cache?", isPresented: $showClearConfirm, @@ -50,59 +32,14 @@ struct SettingsView: View { } message: { Text("Station data will be re-fetched from the server when you next open the map.") } + .sheet(isPresented: $showDevSettings) { + DeveloperSettingsSheet(api: api) + } } } // MARK: - Sections - private var cacheSection: some View { - Section { - LabeledContent("On device") { - Text("\(store.byId.count) stations") - .foregroundStyle(.secondary) - } - - if isLoadingHealth { - LabeledContent("Server") { - ProgressView() - } - } else if let h = health { - LabeledContent("Server") { - Text("\(h.cachedStations) stations") - .foregroundStyle(.secondary) - } - if let raw = h.newestFetch, - let date = Self.iso8601.date(from: raw) { - LabeledContent("Last fetched") { - Text(date, format: .relative(presentation: .named)) - .foregroundStyle(.secondary) - } - } - } else if !baseURL.isEmpty { - LabeledContent("Server") { - Text("Unavailable") - .foregroundStyle(.tertiary) - } - } - - Button("Clear on-device cache", role: .destructive) { - showClearConfirm = true - } - .disabled(store.byId.isEmpty) - } header: { - HStack { - Text("Cache") - Spacer() - if !baseURL.isEmpty { - Button("Refresh") { Task { await loadHealth() } } - .disabled(isLoadingHealth) - .font(.footnote) - .textCase(nil) - } - } - } - } - private var bestValueSection: some View { Section { Stepper(value: $balanceMpg, in: 10...60, step: 1) { @@ -118,51 +55,91 @@ struct SettingsView: View { } } - @ViewBuilder - private var authSection: some View { - Section("Authentication") { - TextField("Server URL", text: $baseURL, prompt: Text("https://overpass.dunkirk.sh")) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) - .keyboardType(.URL) - - if hasApiKey { - Label("API key registered", systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) - Button("Reset API key", role: .destructive) { - KeychainService.delete(forKey: "user_api_key") - hasApiKey = false - } - } else { - SecureField("Device secret", text: $deviceSecret) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + private var cacheSection: some View { + Section("Cache") { + LabeledContent("Cached stations") { + Text("\(store.byId.count)") + .foregroundStyle(.secondary) + } + Button("Clear Cache", role: .destructive) { + showClearConfirm = true + } + .disabled(store.byId.isEmpty) + } + } - Button(isRegistering ? "Registering…" : "Register API key") { - Task { await register() } - } - .disabled(isRegistering || deviceSecret.isEmpty || baseURL.isEmpty) + private var aboutSection: some View { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" - if let error = registrationError { - Text(error) - .font(.caption) - .foregroundStyle(.red) + return Section { + if storeManager.isUnlocked { + LabeledContent("Overpass", value: storeManager.purchaseDate.map { "Purchased \(RelativeDateTimeFormatter().localizedString(for: $0, relativeTo: .now))" } ?? "Purchased") + .foregroundStyle(.secondary) + } else if storeManager.isInTrial { + let days = storeManager.daysRemainingInTrial + LabeledContent("Free Trial") { + Text("\(days) day\(days == 1 ? "" : "s") remaining") + .foregroundStyle(days <= 2 ? .orange : .secondary) } } - } - } - private var versionSection: some View { - Section { - let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" - let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" LabeledContent("Version", value: "\(version) (\(build))") .foregroundStyle(.secondary) + .contentShape(Rectangle()) + .onTapGesture { + devTapCount += 1 + if devTapCount >= 7 { + devTapCount = 0 + showDevSettings = true + } + } + + Button { + Task { + isRestoring = true + restoreSucceeded = nil + await storeManager.restore() + restoreSucceeded = storeManager.isUnlocked + isRestoring = false + if restoreSucceeded == false { + try? await Task.sleep(for: .seconds(30)) + restoreSucceeded = nil + } + } + } label: { + if isRestoring { + HStack { + ProgressView() + Text("Restoring…") + } + } else if restoreSucceeded == true { + HStack { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + Text("Purchase Restored") + } + } else if restoreSucceeded == false { + HStack { + Image(systemName: "xmark.circle.fill") + Text("Nothing to Restore") + } + .foregroundStyle(.secondary) + } else { + Text("Restore Purchase") + } + } + .disabled(isRestoring) + + if storeManager.isInTrial { + Button(storeManager.isPurchasing ? "Purchasing…" : "Buy Overpass — \(storeManager.product?.displayPrice ?? "$2.99")") { + Task { try? await storeManager.purchase() } + } + .disabled(storeManager.isPurchasing || storeManager.product == nil) + } } footer: { HStack { Spacer() - Text("Made with ♥ by ") + - Text("[Kieran Klukas](https://dunkirk.sh)") + Text("Made with ♥ by [Kieran Klukas](https://dunkirk.sh)") Spacer() } .font(.footnote) @@ -170,14 +147,78 @@ struct SettingsView: View { .padding(.top, 8) } } +} + +// MARK: - Developer Settings Sheet - // MARK: - Actions +private struct DeveloperSettingsSheet: View { + let api: APIClient - private func loadHealth() async { - guard !baseURL.isEmpty else { return } - isLoadingHealth = true - health = try? await api.fetchHealth() - isLoadingHealth = false + @State private var baseURL: String = "" + @State private var deviceSecret: String = "" + @State private var registrationError: String? + @State private var isRegistering = false + @State private var hasApiKey = false + + var body: some View { + NavigationStack { + Form { + Section { + Text("Hi! If you found this I bet you like breaking things. Shoot me an email and negotiate your price :)") + .font(.subheadline) + .foregroundStyle(.secondary) + .listRowBackground(Color.clear) + } + + Section("Server") { + TextField("Base URL", text: $baseURL, prompt: Text("https://overpass.dunkirk.sh")) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } + + Section("Authentication") { + if hasApiKey { + Label("API key registered", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + Button("Reset API key", role: .destructive) { + KeychainService.delete(forKey: "user_api_key") + hasApiKey = false + } + } else { + SecureField("Device secret", text: $deviceSecret) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button(isRegistering ? "Registering…" : "Register API key") { + Task { await register() } + } + .disabled(isRegistering || deviceSecret.isEmpty || baseURL.isEmpty) + + if let error = registrationError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + .navigationTitle("Developer") + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + baseURL = api.baseURL + deviceSecret = KeychainService.load(forKey: "device_secret") ?? "" + hasApiKey = KeychainService.load(forKey: "user_api_key") != nil + } + .onChange(of: baseURL) { _, new in api.baseURL = new } + .onChange(of: deviceSecret) { _, new in + if new.isEmpty { + KeychainService.delete(forKey: "device_secret") + } else { + KeychainService.save(new, forKey: "device_secret") + } + } } private func register() async { diff --git a/overpass/overpass.xcodeproj/project.pbxproj b/overpass/overpass.xcodeproj/project.pbxproj index 396caeb..c8924d9 100644 --- a/overpass/overpass.xcodeproj/project.pbxproj +++ b/overpass/overpass.xcodeproj/project.pbxproj @@ -25,10 +25,13 @@ 0B5E3B622F75C40F005F7F70 /* PrefetchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */; }; 0B5E3B6A2F75F12D005F7F70 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 0B5E3B692F75F12D005F7F70 /* GRDB */; }; 0B5E3B6C2F76D765005F7F70 /* BackgroundRefreshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */; }; + 0B5E3B702F77891B005F7F70 /* StoreKit Config.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 0B5E3B6F2F77891B005F7F70 /* StoreKit Config.storekit */; }; 0B7A9C030000000000000000 /* RouteIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C020000000000000000 /* RouteIntents.swift */; }; 0B7A9C050000000000000000 /* SavedRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C040000000000000000 /* SavedRoute.swift */; }; 0B7A9C070000000000000000 /* RouteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C060000000000000000 /* RouteStore.swift */; }; 0B7A9C090000000000000000 /* RouteEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B7A9C080000000000000000 /* RouteEditorView.swift */; }; + 0BAA1C010000000000000000 /* StoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAA1C000000000000000000 /* StoreManager.swift */; }; + 0BAA1C030000000000000000 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAA1C020000000000000000 /* PaywallView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -50,10 +53,13 @@ 0B5E3B5F2F75C0F1005F7F70 /* StationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationStore.swift; sourceTree = ""; }; 0B5E3B612F75C40F005F7F70 /* PrefetchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefetchView.swift; sourceTree = ""; }; 0B5E3B6B2F76D765005F7F70 /* BackgroundRefreshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundRefreshService.swift; sourceTree = ""; }; + 0B5E3B6F2F77891B005F7F70 /* StoreKit Config.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "StoreKit Config.storekit"; sourceTree = ""; }; 0B7A9C020000000000000000 /* RouteIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteIntents.swift; sourceTree = ""; }; 0B7A9C040000000000000000 /* SavedRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedRoute.swift; sourceTree = ""; }; 0B7A9C060000000000000000 /* RouteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteStore.swift; sourceTree = ""; }; 0B7A9C080000000000000000 /* RouteEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteEditorView.swift; sourceTree = ""; }; + 0BAA1C000000000000000000 /* StoreManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreManager.swift; sourceTree = ""; }; + 0BAA1C020000000000000000 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -71,6 +77,7 @@ 0B5E3B2C2F75ACD9005F7F70 = { isa = PBXGroup; children = ( + 0B5E3B6F2F77891B005F7F70 /* StoreKit Config.storekit */, 0B5E3B452F75B002005F7F70 /* Models */, 0B5E3B492F75B002005F7F70 /* Services */, 0B5E3B4F2F75B002005F7F70 /* Views */, @@ -83,14 +90,6 @@ ); sourceTree = ""; }; - 0B7A9C010000000000000000 /* AppIntents */ = { - isa = PBXGroup; - children = ( - 0B7A9C020000000000000000 /* RouteIntents.swift */, - ); - path = AppIntents; - sourceTree = ""; - }; 0B5E3B452F75B002005F7F70 /* Models */ = { isa = PBXGroup; children = ( @@ -110,6 +109,7 @@ 0B5E3B472F75B002005F7F70 /* KeychainService.swift */, 0B5E3B482F75B002005F7F70 /* LocationManager.swift */, 0B7A9C060000000000000000 /* RouteStore.swift */, + 0BAA1C000000000000000000 /* StoreManager.swift */, ); path = Services; sourceTree = ""; @@ -124,10 +124,19 @@ 0B5E3B4C2F75B002005F7F70 /* SettingsView.swift */, 0B5E3B4D2F75B002005F7F70 /* StationDetailView.swift */, 0B5E3B4E2F75B002005F7F70 /* StationRow.swift */, + 0BAA1C020000000000000000 /* PaywallView.swift */, ); path = Views; sourceTree = ""; }; + 0B7A9C010000000000000000 /* AppIntents */ = { + isa = PBXGroup; + children = ( + 0B7A9C020000000000000000 /* RouteIntents.swift */, + ); + path = AppIntents; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -194,6 +203,7 @@ buildActionMask = 2147483647; files = ( 0B5E3B5E2F75C0B6005F7F70 /* gas.icon in Resources */, + 0B5E3B702F77891B005F7F70 /* StoreKit Config.storekit in Resources */, 0B5E3B3D2F75ACDA005F7F70 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -224,6 +234,8 @@ 0B7A9C050000000000000000 /* SavedRoute.swift in Sources */, 0B7A9C070000000000000000 /* RouteStore.swift in Sources */, 0B7A9C090000000000000000 /* RouteEditorView.swift in Sources */, + 0BAA1C010000000000000000 /* StoreManager.swift in Sources */, + 0BAA1C030000000000000000 /* PaywallView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -358,6 +370,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = M67B42LX8D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -393,6 +406,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = M67B42LX8D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/overpass/overpass.xcodeproj/xcshareddata/xcschemes/overpass.xcscheme b/overpass/overpass.xcodeproj/xcshareddata/xcschemes/overpass.xcscheme new file mode 100644 index 0000000..d045d6d --- /dev/null +++ b/overpass/overpass.xcodeproj/xcshareddata/xcschemes/overpass.xcscheme @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/overpass/overpassApp.swift b/overpass/overpassApp.swift index b8351e3..3440a8c 100644 --- a/overpass/overpassApp.swift +++ b/overpass/overpassApp.swift @@ -52,6 +52,7 @@ struct overpassApp: App { @StateObject private var eia = EIAService.shared @StateObject private var store = StationStore.shared @StateObject private var routeStore = RouteStore.shared + private let storeManager = StoreManager.shared @Environment(\.scenePhase) private var scenePhase var body: some Scene { @@ -61,7 +62,16 @@ struct overpassApp: App { .environmentObject(eia) .environmentObject(store) .environmentObject(routeStore) - .task { await eia.load(api: api) } + .task { + await eia.load(api: api) + await storeManager.load() + } + .fullScreenCover(isPresented: .init( + get: { !storeManager.hasAccess }, + set: { _ in } + )) { + PaywallView() + } } .onChange(of: scenePhase) { _, phase in if phase == .active {