ads tracking
This commit is contained in:
24
Cable/AppDelegate.swift
Normal file
24
Cable/AppDelegate.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
48
Cable/StatsHeaderContainer.swift
Normal file
48
Cable/StatsHeaderContainer.swift
Normal 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
221
Cable/StoreKitManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user