ads tracking

This commit is contained in:
Stefan Lange-Hegermann
2025-11-05 11:13:40 +01:00
parent 5fcc33529a
commit ced06f9eb6
198 changed files with 21205 additions and 262 deletions

24
Cable/AppDelegate.swift Normal file
View File

@@ -0,0 +1,24 @@
//
// AppDelegate.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 01.11.25.
//
import Foundation
import PostHog
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
let POSTHOG_API_KEY = "phc_icZY61N3vdg4Sr3lzz9DNAqCRh6hCorVJbytduWORO9"
let POSTHOG_HOST = "https://eu.i.posthog.com"
let config = PostHogConfig(apiKey: POSTHOG_API_KEY, host: POSTHOG_HOST)
PostHogSDK.shared.setup(config)
NSLog("Launched")
return true
}
}

View File

@@ -44,7 +44,7 @@
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.31765,0.56494,0.59766,1.00000"
"solid" : "display-p3:0.31765,0.56471,0.59608,1.00000"
}
},
{

View File

@@ -154,26 +154,7 @@ struct BatteriesView: View {
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
summarySection
List {
ForEach(batteries) { battery in
Button {
onEdit(battery)
} label: {
batteryRow(for: battery)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, $editMode)
batteriesListWithHeader
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -197,7 +178,13 @@ struct BatteriesView: View {
}
}
private var summarySection: some View {
private var batteryStatsHeader: some View {
StatsHeaderContainer {
batterySummaryContent
}
}
private var batterySummaryContent: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
@@ -225,15 +212,47 @@ struct BatteriesView: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemGroupedBackground))
Divider()
.background(Color(.separator))
}
}
@ViewBuilder
private var batteriesListWithHeader: some View {
if #available(iOS 26.0, *) {
baseBatteriesList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
batteryStatsHeader
}
} else {
baseBatteriesList
.safeAreaInset(edge: .top, spacing: 0) {
batteryStatsHeader
}
}
}
private var baseBatteriesList: some View {
List {
ForEach(batteries) { battery in
Button {
onEdit(battery)
} label: {
batteryRow(for: battery)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, $editMode)
}
private func batteryRow(for battery: SavedBattery) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {

View File

@@ -9,6 +9,7 @@ struct BatteryEditorView: View {
@State private var minimumTemperatureInput: String = ""
@State private var maximumTemperatureInput: String = ""
@State private var showingAppearanceEditor = false
@EnvironmentObject private var storeKitManager: StoreKitManager
@State private var hasActiveProSubscription = false
let onSave: (BatteryConfiguration) -> Void
@@ -532,7 +533,10 @@ struct BatteryEditorView: View {
CableProPaywallView(isPresented: $showingProUpsell)
}
.task {
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
hasActiveProSubscription = storeKitManager.isProUnlocked
}
.onReceive(storeKitManager.$status) { _ in
hasActiveProSubscription = storeKitManager.isProUnlocked
}
.alert(
NSLocalizedString(

View File

@@ -10,7 +10,9 @@ import SwiftData
@main
struct CableApp: App {
@StateObject private var unitSettings = UnitSystemSettings()
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var unitSettings: UnitSystemSettings
@StateObject private var storeKitManager: StoreKitManager
var sharedModelContainer: ModelContainer = {
do {
@@ -31,6 +33,9 @@ struct CableApp: App {
}()
init() {
let unitSettings = UnitSystemSettings()
_unitSettings = StateObject(wrappedValue: unitSettings)
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
#if DEBUG
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
#endif
@@ -40,6 +45,7 @@ struct CableApp: App {
WindowGroup {
ContentView()
.environmentObject(unitSettings)
.environmentObject(storeKitManager)
}
.modelContainer(sharedModelContainer)
}

View File

@@ -110,34 +110,20 @@ struct ChargersView: View {
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
summarySection
List {
ForEach(chargers) { charger in
Button {
onEdit(charger)
} label: {
chargerRow(for: charger)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, $editMode)
.accessibilityIdentifier("chargers-list")
chargersListWithHeader
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
private var summarySection: some View {
private var chargerStatsHeader: some View {
StatsHeaderContainer {
chargerSummaryContent
}
}
private var chargerSummaryContent: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
@@ -157,18 +143,50 @@ struct ChargersView: View {
)
}
}
.padding(.trailing, 16)
.padding(.horizontal, 2)
}
.scrollClipDisabled(true)
.scrollClipDisabled(false)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
.background(Color(.separator))
.padding(.leading, 0)
}
.background(Color(.systemGroupedBackground))
}
@ViewBuilder
private var chargersListWithHeader: some View {
if #available(iOS 26.0, *) {
baseChargersList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
chargerStatsHeader
}
} else {
baseChargersList
.safeAreaInset(edge: .top, spacing: 0) {
chargerStatsHeader
}
}
}
private var baseChargersList: some View {
List {
ForEach(chargers) { charger in
Button {
onEdit(charger)
} label: {
chargerRow(for: charger)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, $editMode)
.accessibilityIdentifier("chargers-list")
}
private var summaryMetrics: [SummaryMetric] {

View File

@@ -29,6 +29,7 @@ struct CalculatorView: View {
@State private var presentedAffiliateLink: AffiliateLinkInfo?
@State private var completedItemIDs: Set<String>
@State private var isAdvancedExpanded = false
@EnvironmentObject private var storeKitManager: StoreKitManager
@State private var hasActiveProSubscription = false
let savedLoad: SavedLoad?
@@ -80,7 +81,10 @@ struct CalculatorView: View {
)
)
.task {
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
hasActiveProSubscription = storeKitManager.isProUnlocked
}
.onReceive(storeKitManager.$status) { _ in
hasActiveProSubscription = storeKitManager.isProUnlocked
}
}

View File

@@ -8,6 +8,7 @@
import SwiftUI
import SwiftData
import PostHog
struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@@ -134,7 +135,7 @@ struct LoadsView: View {
.toolbar {
ToolbarItem(placement: .principal) {
Button(action: {
showingSystemEditor = true
presentSystemEditor(source: "toolbar")
}) {
HStack(spacing: 8) {
ZStack {
@@ -258,13 +259,20 @@ struct LoadsView: View {
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async {
showingSystemEditor = true
presentSystemEditor(source: "auto")
}
}
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
hasOpenedLoadOnAppear = true
DispatchQueue.main.async {
PostHogSDK.shared.capture(
"Load Opened",
properties: [
"mode": loadToOpen.isWattMode ? "watt" : "amp",
"system": system.name
]
)
newLoadToEdit = loadToOpen
}
}
@@ -287,109 +295,116 @@ struct LoadsView: View {
onSelectBatteries: { selectedComponentTab = .batteries },
onSelectChargers: { selectedComponentTab = .chargers },
onCreateLoad: { createNewLoad() },
onBrowseLibrary: { showingComponentLibrary = true },
onShowBillOfMaterials: { showingSystemBOM = true },
onBrowseLibrary: { openComponentLibrary(source: "overview") },
onShowBillOfMaterials: { openBillOfMaterials() },
onCreateBattery: { startBatteryConfiguration() },
onCreateCharger: { startChargerConfiguration() }
)
.accessibilityIdentifier("system-overview")
}
private var summarySection: some View {
ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(loadsSummaryTitle)
.font(.headline.weight(.semibold))
Spacer()
private var loadsStatsHeader: some View {
StatsHeaderContainer {
loadsSummaryContent
}
}
private var loadsSummaryContent: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(loadsSummaryTitle)
.font(.headline.weight(.semibold))
Spacer()
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(savedLoads.count)",
tint: .blue
)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
)
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(savedLoads.count)",
tint: .blue
)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
)
}
VStack(alignment: .leading, spacing: 12) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(savedLoads.count)",
tint: .blue
)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
)
}
}
if let status = loadStatus {
Button {
activeStatus = status
} label: {
statusBanner(for: status)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 12) {
summaryMetric(
icon: "square.stack.3d.up",
label: loadsCountLabel,
value: "\(savedLoads.count)",
tint: .blue
)
summaryMetric(
icon: "bolt.fill",
label: loadsCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
summaryMetric(
icon: "gauge.medium",
label: loadsPowerLabel,
value: formattedPower(totalPower),
tint: .green
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemGroupedBackground))
Divider()
.background(Color(.separator))
libraryButton
.padding(.trailing, 16)
.padding(.bottom, 6)
if let status = loadStatus {
Button {
activeStatus = status
} label: {
statusBanner(for: status)
}
.buttonStyle(.plain)
}
}
}
private var libraryButton: some View {
Button {
showingComponentLibrary = true
openComponentLibrary(source: "library-button")
} label: {
Label(
String(
localized: "loads.library.button",
bundle: .main,
comment: "Button title to open component library"
),
systemImage: "books.vertical"
)
.font(.footnote.weight(.semibold))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
Group {
if #available(iOS 26.0, *) {
libraryButtonLabel
.padding(.horizontal, 18)
.padding(.vertical, 12)
.glassEffect(.regular, in: .capsule)
} else {
libraryButtonLabel
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
}
}
}
.buttonStyle(.plain)
.tint(.accentColor)
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
}
private var libraryButtonLabel: some View {
Label(
String(
localized: "loads.library.button",
bundle: .main,
comment: "Button title to open component library"
),
systemImage: "books.vertical"
)
.font(.footnote.weight(.semibold))
}
private var componentsTab: some View {
@@ -398,37 +413,69 @@ struct LoadsView: View {
OnboardingInfoView(
configuration: .loads(),
onPrimaryAction: { createNewLoad() },
onSecondaryAction: { showingComponentLibrary = true }
onSecondaryAction: { openComponentLibrary(source: "components-onboarding") }
)
.padding(.horizontal, 0)
} else {
summarySection
List {
ForEach(savedLoads) { load in
Button {
selectLoad(load)
} label: {
loadRow(for: load)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: deleteLoads)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.accessibilityIdentifier("loads-list")
.environment(\.editMode, $editMode)
loadsListWithHeader
}
}
.background(Color(.systemGroupedBackground))
}
@ViewBuilder
private var loadsListWithHeader: some View {
Group {
if #available(iOS 26.0, *) {
baseLoadsList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
loadsStatsHeader
}
} else {
baseLoadsList
.safeAreaInset(edge: .top, spacing: 0) {
loadsStatsHeader
}
}
}
.overlay(alignment: .bottomTrailing) {
libraryButton
.padding(.trailing, 24)
.padding(.bottom, 24)
}
}
private var baseLoadsList: some View {
List {
ForEach(savedLoads) { load in
Button {
selectLoad(load)
} label: {
loadRow(for: load)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: deleteLoads)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.accessibilityIdentifier("loads-list")
.environment(\.editMode, $editMode)
}
private func selectLoad(_ load: SavedLoad) {
PostHogSDK.shared.capture(
"Load Opened",
properties: [
"mode": load.isWattMode ? "watt" : "amp",
"system": system.name
]
)
newLoadToEdit = load
}
@@ -699,14 +746,6 @@ struct LoadsView: View {
)
}
private func deleteLoads(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(savedLoads[index])
}
}
}
private func handlePrimaryAction() {
switch selectedComponentTab {
case .overview:
@@ -719,6 +758,54 @@ struct LoadsView: View {
startChargerConfiguration()
}
}
private func presentSystemEditor(source: String) {
PostHogSDK.shared.capture(
"System Editor Opened",
properties: [
"source": source,
"system": system.name
]
)
showingSystemEditor = true
}
private func openComponentLibrary(source: String) {
PostHogSDK.shared.capture(
"Component Library Opened",
properties: [
"source": source,
"system": system.name
]
)
showingComponentLibrary = true
}
private func openBillOfMaterials() {
PostHogSDK.shared.capture(
"Bill Of Materials Opened",
properties: [
"system": system.name
]
)
showingSystemBOM = true
}
private func deleteLoads(offsets: IndexSet) {
let loadsToDelete = offsets.map { savedLoads[$0] }
withAnimation {
for load in loadsToDelete {
PostHogSDK.shared.capture(
"Load Deleted",
properties: [
"name": load.name,
"system": system.name
]
)
modelContext.delete(load)
}
}
}
private func createNewLoad() {
let newLoad = SystemComponentsPersistence.createDefaultLoad(
@@ -728,10 +815,24 @@ struct LoadsView: View {
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
PostHogSDK.shared.capture(
"Load Created",
properties: [
"name": newLoad.name,
"system": system.name
]
)
newLoadToEdit = newLoad
}
private func startBatteryConfiguration() {
PostHogSDK.shared.capture(
"Battery Editor Opened",
properties: [
"source": "create",
"system": system.name
]
)
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
for: system,
existingLoads: savedLoads,
@@ -741,20 +842,46 @@ struct LoadsView: View {
}
private func saveBattery(_ configuration: BatteryConfiguration) {
let isExisting = savedBatteries.contains { $0.id == configuration.id }
SystemComponentsPersistence.saveBattery(
configuration,
for: system,
existingBatteries: savedBatteries,
in: modelContext
)
let eventName = isExisting ? "Battery Updated" : "Battery Created"
PostHogSDK.shared.capture(
eventName,
properties: [
"name": configuration.name,
"system": system.name
]
)
}
private func editBattery(_ battery: SavedBattery) {
PostHogSDK.shared.capture(
"Battery Editor Opened",
properties: [
"source": "edit",
"system": system.name
]
)
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
}
private func deleteBatteries(_ offsets: IndexSet) {
let batteriesToDelete = offsets.map { savedBatteries[$0] }
withAnimation {
for battery in batteriesToDelete {
PostHogSDK.shared.capture(
"Battery Deleted",
properties: [
"name": battery.name,
"system": system.name
]
)
}
SystemComponentsPersistence.deleteBatteries(
at: offsets,
from: savedBatteries,
@@ -764,6 +891,13 @@ struct LoadsView: View {
}
private func startChargerConfiguration() {
PostHogSDK.shared.capture(
"Charger Editor Opened",
properties: [
"source": "create",
"system": system.name
]
)
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
for: system,
existingLoads: savedLoads,
@@ -773,20 +907,46 @@ struct LoadsView: View {
}
private func saveCharger(_ configuration: ChargerConfiguration) {
let isExisting = savedChargers.contains { $0.id == configuration.id }
SystemComponentsPersistence.saveCharger(
configuration,
for: system,
existingChargers: savedChargers,
in: modelContext
)
let eventName = isExisting ? "Charger Updated" : "Charger Created"
PostHogSDK.shared.capture(
eventName,
properties: [
"name": configuration.name,
"system": system.name
]
)
}
private func editCharger(_ charger: SavedCharger) {
PostHogSDK.shared.capture(
"Charger Editor Opened",
properties: [
"source": "edit",
"system": system.name
]
)
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
}
private func deleteChargers(_ offsets: IndexSet) {
let chargersToDelete = offsets.map { savedChargers[$0] }
withAnimation {
for charger in chargersToDelete {
PostHogSDK.shared.capture(
"Charger Deleted",
properties: [
"name": charger.name,
"system": system.name
]
)
}
SystemComponentsPersistence.deleteChargers(
at: offsets,
from: savedChargers,
@@ -804,6 +964,14 @@ struct LoadsView: View {
existingBatteries: savedBatteries,
existingChargers: savedChargers
)
PostHogSDK.shared.capture(
"Library Load Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
newLoadToEdit = newLoad
}

View File

@@ -592,6 +592,16 @@ struct SystemOverviewView: View {
}
}
private var totalAverageLoadPower: Double {
loads.reduce(0) { result, load in
let power = max(load.power, 0)
guard power > 0 else { return result }
let dutyCycleFraction = max(min(load.dutyCyclePercent, 100), 0) / 100
let usageFraction = max(min(load.dailyUsageHours, 24), 0) / 24
return result + power * dutyCycleFraction * usageFraction
}
}
private var totalCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.capacityAmpHours
@@ -714,9 +724,11 @@ struct SystemOverviewView: View {
}
private var completedBOMItemCount: Int {
settledLoads.reduce(into: Set<String>()) { partialResult, load in
load.bomCompletedItemIDs.forEach { partialResult.insert($0) }
}.count
settledLoads.reduce(0) { result, load in
let uniqueItems = Set(load.bomCompletedItemIDs)
let cappedCount = min(uniqueItems.count, Self.bomItemsPerLoad)
return result + cappedCount
}
}
private var bomItemsCount: Int {
@@ -794,8 +806,8 @@ struct SystemOverviewView: View {
}
private var estimatedRuntimeHours: Double? {
guard totalPower > 0, totalUsableEnergy > 0 else { return nil }
let hours = totalUsableEnergy / totalPower
guard totalAverageLoadPower > 0, totalUsableEnergy > 0 else { return nil }
let hours = totalUsableEnergy / totalAverageLoadPower
return hours.isFinite && hours > 0 ? hours : nil
}

View File

@@ -118,7 +118,9 @@ final class CableProPaywallViewModel: ObservableObject {
for await result in Transaction.currentEntitlements {
switch result {
case .verified(let transaction):
unlocked.insert(transaction.productID)
if productIdentifiers.contains(transaction.productID) {
unlocked.insert(transaction.productID)
}
case .unverified:
continue
}
@@ -132,14 +134,12 @@ struct CableProPaywallView: View {
@Environment(\.dismiss) private var dismiss
@Binding var isPresented: Bool
@EnvironmentObject private var unitSettings: UnitSystemSettings
@EnvironmentObject private var storeKitManager: StoreKitManager
@StateObject private var viewModel: CableProPaywallViewModel
@State private var alertInfo: PaywallAlert?
private static let defaultProductIds = [
"app.voltplan.cable.weekly",
"app.voltplan.cable.yearly"
]
private static let defaultProductIds = StoreKitManager.subscriptionProductIDs
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
_isPresented = isPresented
@@ -168,9 +168,12 @@ struct CableProPaywallView: View {
}
.task {
await viewModel.loadProducts(force: true)
unitSettings.isProUnlocked = !viewModel.purchasedProductIDs.isEmpty
await storeKitManager.refreshEntitlements()
}
.refreshable {
await viewModel.loadProducts(force: true)
await storeKitManager.refreshEntitlements()
}
.refreshable { await viewModel.loadProducts(force: true) }
}
.onChange(of: viewModel.alert) { newValue in
alertInfo = newValue
@@ -186,7 +189,7 @@ struct CableProPaywallView: View {
)
}
.onChange(of: viewModel.purchasedProductIDs) { newValue in
unitSettings.isProUnlocked = !newValue.isEmpty
Task { await storeKitManager.refreshEntitlements() }
}
}
@@ -535,5 +538,9 @@ struct PaywallAlert: Identifiable, Equatable {
}
#Preview {
CableProPaywallView(isPresented: .constant(true))
let unitSettings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: unitSettings)
return CableProPaywallView(isPresented: .constant(true))
.environmentObject(unitSettings)
.environmentObject(manager)
}

View File

@@ -7,17 +7,14 @@
import SwiftUI
import SwiftData
import StoreKit
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@EnvironmentObject private var storeKitManager: StoreKitManager
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var showingProPaywall = false
@State private var isLoadingProStatus = true
@State private var proStatus: ProSubscriptionStatus?
var body: some View {
NavigationStack {
@@ -75,29 +72,28 @@ struct SettingsView: View {
}
}
}
.task { await loadProStatus() }
.sheet(isPresented: $showingProPaywall) {
CableProPaywallView(isPresented: $showingProPaywall)
}
.onChange(of: showingProPaywall) { isPresented in
if !isPresented {
Task { await loadProStatus() }
Task { await storeKitManager.refreshEntitlements() }
}
}
.onAppear {
Task { await loadProStatus() }
Task { await storeKitManager.refreshEntitlements() }
}
}
@ViewBuilder
private var proSectionContent: some View {
if isLoadingProStatus {
if storeKitManager.isRefreshing && storeKitManager.status == nil {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if let status = proStatus {
} else if let status = storeKitManager.status {
VStack(alignment: .leading, spacing: 8) {
Label(status.displayName, systemImage: "checkmark.seal.fill")
.font(.headline)
@@ -113,6 +109,18 @@ struct SettingsView: View {
.font(.footnote)
.foregroundStyle(.secondary)
}
if status.isInGracePeriod {
Text(localizedString("settings.pro.grace_period", defaultValue: "We're retrying your last payment; access remains during the grace period."))
.font(.footnote)
.foregroundStyle(.secondary)
}
if let isAutoRenewEnabled = status.isAutoRenewEnabled, !isAutoRenewEnabled {
Text(localizedString("settings.pro.autorenew.off", defaultValue: "Auto-renew is off—consider renewing to keep Cable PRO."))
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
.font(.footnote)
@@ -149,15 +157,6 @@ struct SettingsView: View {
}
}
@MainActor
private func loadProStatus() async {
isLoadingProStatus = true
defer { isLoadingProStatus = false }
let status = await SettingsView.fetchProStatus()
proStatus = status
unitSettings.isProUnlocked = status != nil
}
private func renewalText(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
@@ -168,7 +167,7 @@ struct SettingsView: View {
return String(format: template, dateString)
}
private func trialMessage(for status: ProSubscriptionStatus) -> String? {
private func trialMessage(for status: StoreKitManager.SubscriptionStatus) -> String? {
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
if days > 0 {
@@ -199,48 +198,15 @@ struct SettingsView: View {
return formatter.string(from: NSNumber(value: value)) ?? String(value)
}
static func fetchProStatus() async -> ProSubscriptionStatus? {
let productIDs = Set(["app.voltplan.cable.weekly", "app.voltplan.cable.yearly"])
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
productIDs.contains(transaction.productID) else { continue }
let product = try? await Product.products(for: [transaction.productID]).first
let displayName = product?.displayName ?? transaction.productID
let renewalDate = transaction.expirationDate
let hasIntroOffer = transaction.offerType == .introductory
let paymentMode = product?.subscription?.introductoryOffer?.paymentMode
let isInTrial = hasIntroOffer && paymentMode == .freeTrial
let trialEndDate = isInTrial ? transaction.expirationDate : nil
return ProSubscriptionStatus(
productId: transaction.productID,
displayName: displayName,
renewalDate: renewalDate,
isInTrial: isInTrial,
trialEndDate: trialEndDate
)
}
return nil
}
private func localizedString(_ key: String, defaultValue: String) -> String {
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
}
struct ProSubscriptionStatus {
let productId: String
let displayName: String
let renewalDate: Date?
let isInTrial: Bool
let trialEndDate: Date?
}
}
#Preview("Settings (Default)") {
let settings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: settings)
return SettingsView()
.environmentObject(settings)
.environmentObject(manager)
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
/// Reusable wrapper that applies the system overview stats card styling to a header view.
struct StatsHeaderContainer<Content: View>: View {
private let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
Group {
if #available(iOS 26.0, *) {
card
.glassEffect(.regular, in: .rect(cornerRadius: 20))
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 12)
} else {
card
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 16)
.background(Color(.systemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(.white.opacity(0.15))
)
}
}
}
private var card: some View {
content
.padding(.vertical, 18)
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(red: 81 / 255, green: 144 / 255, blue: 152 / 255).opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}

221
Cable/StoreKitManager.swift Normal file
View File

@@ -0,0 +1,221 @@
import Foundation
import StoreKit
@MainActor
final class StoreKitManager: ObservableObject {
struct SubscriptionStatus: Equatable {
let productId: String
let displayName: String
let renewalDate: Date?
let isInTrial: Bool
let trialEndDate: Date?
let isInGracePeriod: Bool
let isAutoRenewEnabled: Bool?
}
nonisolated static let subscriptionProductIDs: [String] = [
"app.voltplan.cable.weekly",
"app.voltplan.cable.yearly"
]
@Published private(set) var status: SubscriptionStatus?
@Published private(set) var isRefreshing = false
var isProUnlocked: Bool {
status != nil
}
private let productIDs: Set<String>
private weak var unitSettings: UnitSystemSettings?
private var updatesTask: Task<Void, Never>?
private var productCache: [String: Product] = [:]
init(
productIDs: [String] = StoreKitManager.subscriptionProductIDs,
unitSettings: UnitSystemSettings? = nil
) {
self.productIDs = Set(productIDs)
self.unitSettings = unitSettings
updatesTask = Task { [weak self] in
await self?.observeTransactionUpdates()
}
Task { [weak self] in
await self?.finishUnfinishedTransactions()
await self?.refreshEntitlements()
}
}
deinit {
updatesTask?.cancel()
}
func attachUnitSettings(_ settings: UnitSystemSettings) {
unitSettings = settings
Task { [weak self] in
await self?.refreshEntitlements()
}
}
func refreshEntitlements() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
let resolvedStatus = await loadCurrentStatus()
status = resolvedStatus
unitSettings?.isProUnlocked = resolvedStatus != nil
}
private func loadCurrentStatus() async -> SubscriptionStatus? {
if let entitlementStatus = await statusFromCurrentEntitlements() {
return entitlementStatus
}
return await statusFromLatestTransactions()
}
private func statusFromCurrentEntitlements() async -> SubscriptionStatus? {
var newestTransaction: StoreKit.Transaction?
for await result in StoreKit.Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
productIDs.contains(transaction.productID),
transaction.revocationDate == nil,
!isExpired(transaction) else { continue }
if let existing = newestTransaction {
let existingExpiration = existing.expirationDate ?? .distantPast
let candidateExpiration = transaction.expirationDate ?? .distantPast
if candidateExpiration > existingExpiration {
newestTransaction = transaction
}
} else {
newestTransaction = transaction
}
}
guard let activeTransaction = newestTransaction else { return nil }
return await status(for: activeTransaction)
}
private func statusFromLatestTransactions() async -> SubscriptionStatus? {
var newestTransaction: StoreKit.Transaction?
for productID in productIDs {
guard let latestResult = await StoreKit.Transaction.latest(for: productID) else { continue }
guard case .verified(let transaction) = latestResult,
transaction.revocationDate == nil,
!isExpired(transaction) else { continue }
if let existing = newestTransaction {
let existingExpiration = existing.expirationDate ?? .distantPast
let candidateExpiration = transaction.expirationDate ?? .distantPast
if candidateExpiration > existingExpiration {
newestTransaction = transaction
}
} else {
newestTransaction = transaction
}
}
guard let activeTransaction = newestTransaction else { return nil }
return await status(for: activeTransaction)
}
private func observeTransactionUpdates() async {
for await result in StoreKit.Transaction.updates {
guard !Task.isCancelled else { return }
switch result {
case .verified(let transaction):
await transaction.finish()
await refreshEntitlements()
case .unverified:
continue
}
}
}
private func finishUnfinishedTransactions() async {
for await result in StoreKit.Transaction.unfinished {
guard case .verified(let transaction) = result else { continue }
await transaction.finish()
}
}
private func status(for transaction: StoreKit.Transaction) async -> SubscriptionStatus? {
let product = await product(for: transaction.productID)
let displayName = product?.displayName ?? transaction.productID
var isInGracePeriod = false
var isAutoRenewEnabled: Bool?
var isInTrial = false
var trialEndDate: Date?
if let currentStatus = await transaction.subscriptionStatus {
if currentStatus.state == .inGracePeriod {
isInGracePeriod = true
}
if case .verified(let renewalInfo) = currentStatus.renewalInfo {
isAutoRenewEnabled = renewalInfo.willAutoRenew
if renewalInfo.gracePeriodExpirationDate != nil {
isInGracePeriod = true
}
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
if let offer = renewalInfo.offer, offer.type == .introductory {
isInTrial = true
trialEndDate = transaction.expirationDate
}
} else {
#if compiler(>=5.3)
if renewalInfo.offerType == .introductory {
isInTrial = true
trialEndDate = transaction.expirationDate
}
#endif
}
} else if case .verified(let statusTransaction) = currentStatus.transaction {
if let offer = statusTransaction.offer, offer.type == .introductory {
isInTrial = true
trialEndDate = statusTransaction.expirationDate ?? transaction.expirationDate
}
}
} else if let offer = transaction.offer, offer.type == .introductory {
isInTrial = true
trialEndDate = transaction.expirationDate
}
return SubscriptionStatus(
productId: transaction.productID,
displayName: displayName,
renewalDate: transaction.expirationDate,
isInTrial: isInTrial,
trialEndDate: trialEndDate,
isInGracePeriod: isInGracePeriod,
isAutoRenewEnabled: isAutoRenewEnabled
)
}
private func isExpired(_ transaction: StoreKit.Transaction) -> Bool {
if let expirationDate = transaction.expirationDate {
return expirationDate < Date()
}
return false
}
private func product(for id: String) async -> Product? {
if let cached = productCache[id] {
return cached
}
guard let product = try? await Product.products(for: [id]).first else { return nil }
productCache[id] = product
return product
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI
import PostHog
struct SystemsOnboardingView: View {
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
@@ -92,6 +93,9 @@ struct SystemsOnboardingView: View {
.background(Color(.systemGroupedBackground))
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
.task {
PostHogSDK.shared.capture("Launched")
}
}
private func resetState() {
@@ -102,6 +106,7 @@ struct SystemsOnboardingView: View {
private func createSystem() {
isFieldFocused = false
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
PostHogSDK.shared.capture("System Created", properties: ["name": trimmed])
guard !trimmed.isEmpty else { return }
onCreate(trimmed)
}

View File

@@ -8,6 +8,7 @@
import SwiftUI
import SwiftData
import PostHog
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@@ -107,6 +108,17 @@ struct SystemsView: View {
}
.padding(.vertical, 4)
}
.simultaneousGesture(
TapGesture().onEnded {
PostHogSDK.shared.capture(
"System Opened",
properties: [
"name": system.name,
"source": "list"
]
)
}
)
}
.onDelete(perform: deleteSystems)
}
@@ -117,7 +129,7 @@ struct SystemsView: View {
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingSettings = true
openSettings()
} label: {
Image(systemName: "gearshape")
}
@@ -125,6 +137,7 @@ struct SystemsView: View {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
PostHogSDK.shared.capture("System Create Navigation")
createNewSystem()
}) {
Image(systemName: "plus")
@@ -160,15 +173,44 @@ struct SystemsView: View {
createOnboardingSystem(named: name)
}
}
private func openSettings() {
PostHogSDK.shared.capture("Settings Opened")
showingSettings = true
}
private func createNewSystem() {
let system = makeSystem()
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
PostHogSDK.shared.capture(
"System Created",
properties: [
"name": system.name,
"source": "toolbar"
]
)
navigateToSystem(
system,
presentSystemEditor: true,
loadToOpen: nil,
source: "created"
)
}
private func createNewSystem(named name: String) {
let system = makeSystem(preferredName: name)
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
PostHogSDK.shared.capture(
"System Created",
properties: [
"name": system.name,
"source": "named"
]
)
navigateToSystem(
system,
presentSystemEditor: true,
loadToOpen: nil,
source: "created-named"
)
}
private func createOnboardingSystem(named name: String) {
@@ -176,10 +218,29 @@ struct SystemsView: View {
preferredName: name,
colorName: randomSystemColorName()
)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "onboarding"
)
}
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
private func navigateToSystem(
_ system: ElectricalSystem,
presentSystemEditor: Bool,
loadToOpen: SavedLoad?,
animated: Bool = true,
source: String = "programmatic"
) {
PostHogSDK.shared.capture(
"System Opened",
properties: [
"name": system.name,
"source": source,
"loads": loads(for: system).count
]
)
let target = SystemNavigationTarget(
system: system,
presentSystemEditor: presentSystemEditor,
@@ -228,13 +289,40 @@ struct SystemsView: View {
hasPerformedInitialAutoNavigation = true
guard systems.count == 1, let system = systems.first else { return }
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil, animated: false)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
animated: false,
source: "auto"
)
}
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
PostHogSDK.shared.capture(
"System Created",
properties: [
"name": system.name,
"source": "library"
]
)
let load = createLoad(from: item, in: system)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
PostHogSDK.shared.capture(
"Library Load Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: load,
animated: false,
source: "library"
)
}
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
@@ -306,9 +394,16 @@ struct SystemsView: View {
}
private func deleteSystems(offsets: IndexSet) {
let systemsToDelete = offsets.map { systems[$0] }
withAnimation {
for index in offsets {
let system = systems[index]
for system in systemsToDelete {
PostHogSDK.shared.capture(
"System Deleted",
properties: [
"name": system.name,
"loads": loads(for: system).count
]
)
deleteLoads(for: system)
modelContext.delete(system)
}
@@ -319,6 +414,14 @@ struct SystemsView: View {
let descriptor = FetchDescriptor<SavedLoad>()
if let loads = try? modelContext.fetch(descriptor) {
for load in loads where load.system == system {
PostHogSDK.shared.capture(
"Load Deleted",
properties: [
"name": load.name,
"system": system.name,
"source": "system-delete"
]
)
modelContext.delete(load)
}
}