diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj
index 7e3eddc..c16e5cd 100644
--- a/Cable.xcodeproj/project.pbxproj
+++ b/Cable.xcodeproj/project.pbxproj
@@ -190,7 +190,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1640;
- LastUpgradeCheck = 1640;
+ LastUpgradeCheck = 2600;
TargetAttributes = {
3E5C0BCB2E72C0FD00247EC8 = {
CreatedOnToolsVersion = 16.4;
@@ -298,7 +298,9 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = RE4FXQ754N;
+ ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -329,7 +331,9 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = RE4FXQ754N;
+ ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -410,6 +414,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -467,6 +472,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
diff --git a/Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json b/Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json
new file mode 100644
index 0000000..3ff45c5
--- /dev/null
+++ b/Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "boat-ob.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Cable/Assets.xcassets/boat-onboarding.imageset/boat-ob.png b/Cable/Assets.xcassets/boat-onboarding.imageset/boat-ob.png
new file mode 100644
index 0000000..003afeb
Binary files /dev/null and b/Cable/Assets.xcassets/boat-onboarding.imageset/boat-ob.png differ
diff --git a/Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json b/Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json
new file mode 100644
index 0000000..58f7354
--- /dev/null
+++ b/Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "cabin-ob.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-ob.png b/Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-ob.png
new file mode 100644
index 0000000..0821dcc
Binary files /dev/null and b/Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-ob.png differ
diff --git a/Cable/Assets.xcassets/van-onboarding.imageset/Contents.json b/Cable/Assets.xcassets/van-onboarding.imageset/Contents.json
new file mode 100644
index 0000000..6526bd0
--- /dev/null
+++ b/Cable/Assets.xcassets/van-onboarding.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "van-ob.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Cable/Assets.xcassets/van-onboarding.imageset/van-ob.png b/Cable/Assets.xcassets/van-onboarding.imageset/van-ob.png
new file mode 100644
index 0000000..487d26e
Binary files /dev/null and b/Cable/Assets.xcassets/van-onboarding.imageset/van-ob.png differ
diff --git a/Cable/Cable.entitlements b/Cable/Cable.entitlements
index f42a1dc..8be8d6a 100644
--- a/Cable/Cable.entitlements
+++ b/Cable/Cable.entitlements
@@ -8,9 +8,5 @@
com.apple.developer.icloud-services
- com.apple.security.app-sandbox
-
- com.apple.security.files.user-selected.read-only
-
diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift
index 11f0d08..affc8ec 100644
--- a/Cable/CalculatorView.swift
+++ b/Cable/CalculatorView.swift
@@ -193,7 +193,7 @@ struct CalculatorView: View {
loadConfiguration(from: savedLoad)
}
}
- .onChange(of: completedItemIDs) { _ in
+ .onChange(of: completedItemIDs) { _, _ in
persistCompletedItems()
}
}
@@ -237,7 +237,7 @@ struct CalculatorView: View {
affiliateURL = nil
}
- let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.regionCode
+ let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.region?.identifier
let countryCode = rawCountryCode?.uppercased()
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
diff --git a/Cable/ComponentLibraryView.swift b/Cable/ComponentLibraryView.swift
index 504264b..6dbd2d0 100644
--- a/Cable/ComponentLibraryView.swift
+++ b/Cable/ComponentLibraryView.swift
@@ -40,7 +40,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
}
var primaryAffiliateLink: AffiliateLink? {
- affiliateLink(matching: Locale.current.regionCode)
+ affiliateLink(matching: Locale.current.region?.identifier)
}
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
diff --git a/Cable/ContentView.swift b/Cable/ContentView.swift
index e0c5776..abcbf74 100644
--- a/Cable/ContentView.swift
+++ b/Cable/ContentView.swift
@@ -16,6 +16,38 @@ struct SystemsView: View {
@State private var showingComponentLibrary = false
@State private var showingSettings = false
+ private let systemColorOptions = [
+ "blue", "green", "orange", "red", "purple", "yellow",
+ "pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
+ ]
+ private let defaultSystemIconName = "building.2"
+ private let systemIconMappings: [(keywords: [String], icon: String)] = [
+ (["rv", "van", "camper", "motorhome", "coach"], "bus"),
+ (["truck", "trailer", "rig"], "truck.box"),
+ (["boat", "marine", "yacht", "sail"], "sailboat"),
+ (["plane", "air", "flight"], "airplane"),
+ (["ferry", "ship"], "ferry"),
+ (["house", "home", "cabin", "cottage", "lodge"], "house"),
+ (["building", "office", "warehouse", "factory", "facility"], "building"),
+ (["camp", "tent", "outdoor"], "tent"),
+ (["solar", "sun"], "sun.max"),
+ (["battery", "storage"], "battery.100"),
+ (["server", "data", "network", "rack"], "server.rack"),
+ (["computer", "electronics", "lab", "tech"], "cpu"),
+ (["gear", "mechanic", "machine", "workshop"], "gear"),
+ (["tool", "maintenance", "repair", "shop"], "wrench.adjustable"),
+ (["hammer", "carpentry"], "hammer"),
+ (["light", "lighting", "lamp"], "lightbulb"),
+ (["bolt", "power", "electric"], "bolt"),
+ (["plug"], "powerplug"),
+ (["engine", "generator", "motor"], "engine.combustion"),
+ (["fuel", "diesel", "gas"], "fuelpump"),
+ (["water", "pump", "tank"], "drop"),
+ (["heat", "heater", "furnace"], "flame"),
+ (["cold", "freeze", "cool"], "snowflake"),
+ (["climate", "hvac", "temperature"], "thermometer")
+ ]
+
private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID()
let system: ElectricalSystem
@@ -115,93 +147,8 @@ struct SystemsView: View {
}
private var systemsEmptyState: some View {
- VStack(spacing: 0) {
- Spacer()
-
- VStack(spacing: 24) {
- ZStack {
- Circle()
- .fill(Color.blue.opacity(0.1))
- .frame(width: 80, height: 80)
-
- Image(systemName: "bolt.circle")
- .font(.system(size: 40))
- .foregroundColor(.blue)
- }
-
- VStack(spacing: 8) {
- Text("Welcome to Cable by VoltPlan")
- .font(.title2)
- .fontWeight(.semibold)
- .foregroundColor(.primary)
-
- Text("We'll create your first system and component so you can jump straight into the calculator.")
- .font(.body)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.center)
- .fixedSize(horizontal: false, vertical: true)
- .padding(.horizontal, 32)
- }
-
- VStack(spacing: 12) {
- Button(action: {
- startComponentOnboarding()
- }) {
- HStack(spacing: 8) {
- Image(systemName: "plus.circle.fill")
- .font(.system(size: 16))
- Text("Create Component")
- .fontWeight(.medium)
- }
- .foregroundColor(.white)
- .frame(maxWidth: .infinity)
- .frame(height: 50)
- .background(Color.blue)
- .cornerRadius(12)
- }
- .buttonStyle(.plain)
-
- Button(action: {
- showingComponentLibrary = true
- }) {
- HStack(spacing: 8) {
- Image(systemName: "square.grid.3x3")
- .font(.system(size: 16))
- Text("Browse VoltPlan Library")
- .fontWeight(.medium)
- Image(systemName: "arrow.up.right")
- .font(.system(size: 12))
- }
- .foregroundColor(.blue)
- .frame(maxWidth: .infinity)
- .frame(height: 50)
- .background(Color.blue.opacity(0.1))
- .cornerRadius(12)
- }
- .buttonStyle(.plain)
- }
- .padding(.horizontal, 32)
- }
-
- Spacer()
-
- VStack(spacing: 8) {
- HStack(spacing: 6) {
- Image(systemName: "exclamationmark.triangle.fill")
- .foregroundColor(.orange)
- .font(.system(size: 16))
- Text("Important Safety Notice")
- .font(.headline)
- .fontWeight(.semibold)
- }
-
- Text("This app provides estimates for educational purposes only. Always consult qualified electricians and follow local electrical codes for actual installations. Electrical work can be dangerous and should only be performed by licensed professionals.")
- .font(.caption)
- .foregroundColor(.secondary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, 24)
- }
- .padding(.bottom, 32)
+ SystemsOnboardingView { name in
+ createOnboardingSystem(named: name)
}
}
@@ -210,6 +157,19 @@ struct SystemsView: View {
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
}
+ private func createNewSystem(named name: String) {
+ let system = makeSystem(preferredName: name)
+ navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
+ }
+
+ private func createOnboardingSystem(named name: String) {
+ let system = makeSystem(
+ preferredName: name,
+ colorName: randomSystemColorName()
+ )
+ navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
+ }
+
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
let target = SystemNavigationTarget(
system: system,
@@ -229,56 +189,37 @@ struct SystemsView: View {
}
@discardableResult
- private func makeSystem() -> ElectricalSystem {
+ private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
let existingNames = Set(systems.map { $0.name })
- var systemName = "New System"
- var counter = 1
-
+ let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let baseName = trimmedPreferred.isEmpty ? "New System" : trimmedPreferred
+ var systemName = baseName
+ var counter = 2
+
while existingNames.contains(systemName) {
+ systemName = "\(baseName) \(counter)"
counter += 1
- systemName = "New System \(counter)"
}
-
+
+ let resolvedColorName = colorName ?? "blue"
+ let resolvedIconName = iconName ?? systemIconName(for: systemName)
+
let newSystem = ElectricalSystem(
name: systemName,
location: "",
- iconName: "building.2",
- colorName: "blue"
+ iconName: resolvedIconName,
+ colorName: resolvedColorName
)
modelContext.insert(newSystem)
return newSystem
}
- private func startComponentOnboarding() {
- let system = makeSystem()
- let load = createNewLoad(in: system)
- navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
- }
-
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
let load = createLoad(from: item, in: system)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
}
- private func createNewLoad(in system: ElectricalSystem) -> SavedLoad {
- let newLoad = SavedLoad(
- name: "New Load",
- voltage: 12.0,
- current: 5.0,
- power: 60.0,
- length: 10.0,
- crossSection: 1.0,
- iconName: "lightbulb",
- colorName: "blue",
- isWattMode: false,
- system: system,
- remoteIconURLString: nil
- )
- modelContext.insert(newLoad)
- return newLoad
- }
-
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
let baseName = item.name.isEmpty ? "Library Load" : item.name
let loadName = uniqueLoadName(for: system, startingWith: baseName)
@@ -383,7 +324,25 @@ struct SystemsView: View {
return "\(count) component\(count == 1 ? "" : "s") • \(formattedPower) total"
}
-
+
+ private func randomSystemColorName() -> String {
+ systemColorOptions.randomElement() ?? "blue"
+ }
+
+ private func systemIconName(for name: String) -> String {
+ let normalized = name
+ .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
+ .lowercased()
+
+ for mapping in systemIconMappings {
+ if mapping.keywords.contains(where: { keyword in normalized.contains(keyword) }) {
+ return mapping.icon
+ }
+ }
+
+ return defaultSystemIconName
+ }
+
private func colorForName(_ colorName: String) -> Color {
switch colorName {
case "blue": return .blue
@@ -1194,7 +1153,7 @@ private struct SystemBillOfMaterialsView: View {
case .affiliate(let url):
return url
case .amazonSearch(let query):
- let countryCode = load.affiliateCountryCode ?? Locale.current.regionCode
+ let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
}
}
diff --git a/Cable/LoadEditorView.swift b/Cable/LoadEditorView.swift
index cf29c9e..c481498 100644
--- a/Cable/LoadEditorView.swift
+++ b/Cable/LoadEditorView.swift
@@ -36,10 +36,10 @@ struct LoadEditorView: View {
}
#Preview {
- @State var name = "My Load"
- @State var icon = "lightbulb"
- @State var color = "blue"
- @State var remoteIcon: String? = "https://example.com/icon.png"
+ @Previewable @State var name = "My Load"
+ @Previewable @State var icon = "lightbulb"
+ @Previewable @State var color = "blue"
+ @Previewable @State var remoteIcon: String? = "https://example.com/icon.png"
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color, remoteIconURLString: $remoteIcon)
}
diff --git a/Cable/LoadIconView.swift b/Cable/LoadIconView.swift
index 4169180..49249bb 100644
--- a/Cable/LoadIconView.swift
+++ b/Cable/LoadIconView.swift
@@ -35,7 +35,8 @@ struct LoadIconView: View {
}
}
.frame(width: size, height: size)
- .onChange(of: remoteIconURLString) { _ in
+ .onChange(of: remoteIconURLString) { oldValue, newValue in
+ guard oldValue != newValue else { return }
cachedImage = nil
hasAttemptedLoad = false
}
diff --git a/Cable/SystemEditorView.swift b/Cable/SystemEditorView.swift
index 442b43a..9de0bb5 100644
--- a/Cable/SystemEditorView.swift
+++ b/Cable/SystemEditorView.swift
@@ -57,10 +57,10 @@ struct SystemEditorView: View {
}
#Preview {
- @State var name = "My System"
- @State var location = "Main Building"
- @State var icon = "building.2"
- @State var color = "blue"
+ @Previewable @State var name = "My System"
+ @Previewable @State var location = "Main Building"
+ @Previewable @State var icon = "building.2"
+ @Previewable @State var color = "blue"
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
}
diff --git a/Cable/SystemsOnboardingView.swift b/Cable/SystemsOnboardingView.swift
new file mode 100644
index 0000000..a8388c0
--- /dev/null
+++ b/Cable/SystemsOnboardingView.swift
@@ -0,0 +1,141 @@
+import SwiftUI
+
+struct SystemsOnboardingView: View {
+ @State private var systemName: String = "My System"
+ @State private var carouselStep = 0
+ @FocusState private var isFieldFocused: Bool
+ let onCreate: (String) -> Void
+
+ private let imageNames = [
+ "van-onboarding",
+ "cabin-onboarding",
+ "boat-onboarding"
+ ]
+
+ private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
+ private let animationDuration = 0.8
+
+ private var loopingImages: [String] {
+ guard let first = imageNames.first else { return [] }
+ return imageNames + [first]
+ }
+
+ var body: some View {
+ VStack(spacing: 32) {
+ Spacer(minLength: 32)
+
+ SystemsOnboardingCarousel(images: loopingImages, step: carouselStep)
+ .frame(height: 240)
+ .padding(.horizontal, 0)
+
+ VStack(spacing: 12) {
+ Text("Create your first system")
+ .font(.title2.weight(.semibold))
+ .multilineTextAlignment(.center)
+
+ Text("Give your setup a name so Cable can organize loads, wiring, and recommendations in one place.")
+ .font(.body)
+ .foregroundStyle(Color.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 12)
+ }
+ .padding(.horizontal, 24)
+
+ VStack(spacing: 16) {
+ TextField("System Name", text: $systemName)
+ .textFieldStyle(.roundedBorder)
+ .focused($isFieldFocused)
+ .submitLabel(.done)
+ .onSubmit(createSystem)
+
+ Button(action: createSystem) {
+ HStack(spacing:8) {
+ Image(systemName: "plus.circle.fill")
+ .font(.system(size: 16))
+ Text("Create System")
+ .font(.headline.weight(.semibold))
+ }
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .frame(height: 48)
+ .background(Color.blue)
+ .cornerRadius(12)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 24)
+
+ Spacer(minLength: 24)
+ }
+ .padding(.bottom, 32)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(.systemGroupedBackground))
+ .onAppear(perform: resetState)
+ .onReceive(timer) { _ in advanceCarousel() }
+ }
+
+ private func resetState() {
+ systemName = "My System"
+ carouselStep = 0
+ }
+
+ private func createSystem() {
+ isFieldFocused = false
+ let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return }
+ onCreate(trimmed)
+ }
+
+ private func advanceCarousel() {
+ guard imageNames.count > 1 else { return }
+ let next = carouselStep + 1
+
+ withAnimation(.easeInOut(duration: animationDuration)) {
+ carouselStep = next
+ }
+
+ if next == imageNames.count {
+ DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
+ withAnimation(.none) {
+ carouselStep = 0
+ }
+ }
+ }
+ }
+}
+
+#Preview {
+ SystemsOnboardingView { _ in }
+}
+
+private struct SystemsOnboardingCarousel: View {
+ let images: [String]
+ let step: Int
+
+ var body: some View {
+ GeometryReader { geometry in
+ let width = geometry.size.width
+ let height = geometry.size.height
+
+ ZStack {
+
+ if images.isEmpty {
+ Image(systemName: "photo")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+ } else {
+ HStack(spacing: 0) {
+ ForEach(Array(images.enumerated()), id: \.offset) { _, name in
+ Image(name)
+ .resizable()
+ .scaledToFit()
+ .frame(width: width, height: height)
+ }
+ }
+ .offset(x: -CGFloat(step) * width)
+ }
+ }
+ .clipped()
+ }
+ }
+}