graphically pleasing onboarding

This commit is contained in:
Stefan Lange-Hegermann
2025-09-29 08:58:03 +02:00
parent 5fb8997ab9
commit 0842815133
15 changed files with 304 additions and 138 deletions

View File

@@ -190,7 +190,7 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1640; LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1640; LastUpgradeCheck = 2600;
TargetAttributes = { TargetAttributes = {
3E5C0BCB2E72C0FD00247EC8 = { 3E5C0BCB2E72C0FD00247EC8 = {
CreatedOnToolsVersion = 16.4; CreatedOnToolsVersion = 16.4;
@@ -298,7 +298,9 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist; INFOPLIST_FILE = Cable/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -329,7 +331,9 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = RE4FXQ754N; DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist; INFOPLIST_FILE = Cable/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -410,6 +414,7 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
}; };
@@ -467,6 +472,7 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
}; };

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

View File

@@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -8,9 +8,5 @@
<array/> <array/>
<key>com.apple.developer.icloud-services</key> <key>com.apple.developer.icloud-services</key>
<array/> <array/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -193,7 +193,7 @@ struct CalculatorView: View {
loadConfiguration(from: savedLoad) loadConfiguration(from: savedLoad)
} }
} }
.onChange(of: completedItemIDs) { _ in .onChange(of: completedItemIDs) { _, _ in
persistCompletedItems() persistCompletedItems()
} }
} }
@@ -237,7 +237,7 @@ struct CalculatorView: View {
affiliateURL = nil affiliateURL = nil
} }
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.regionCode let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.region?.identifier
let countryCode = rawCountryCode?.uppercased() let countryCode = rawCountryCode?.uppercased()
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 } let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }

View File

@@ -40,7 +40,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
} }
var primaryAffiliateLink: AffiliateLink? { var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.regionCode) affiliateLink(matching: Locale.current.region?.identifier)
} }
func affiliateLink(matching regionCode: String?) -> AffiliateLink? { func affiliateLink(matching regionCode: String?) -> AffiliateLink? {

View File

@@ -16,6 +16,38 @@ struct SystemsView: View {
@State private var showingComponentLibrary = false @State private var showingComponentLibrary = false
@State private var showingSettings = 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 { private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID() let id = UUID()
let system: ElectricalSystem let system: ElectricalSystem
@@ -115,93 +147,8 @@ struct SystemsView: View {
} }
private var systemsEmptyState: some View { private var systemsEmptyState: some View {
VStack(spacing: 0) { SystemsOnboardingView { name in
Spacer() createOnboardingSystem(named: name)
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)
} }
} }
@@ -210,6 +157,19 @@ struct SystemsView: View {
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil) 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) { private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
let target = SystemNavigationTarget( let target = SystemNavigationTarget(
system: system, system: system,
@@ -229,56 +189,37 @@ struct SystemsView: View {
} }
@discardableResult @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 }) let existingNames = Set(systems.map { $0.name })
var systemName = "New System" let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var counter = 1 let baseName = trimmedPreferred.isEmpty ? "New System" : trimmedPreferred
var systemName = baseName
var counter = 2
while existingNames.contains(systemName) { while existingNames.contains(systemName) {
systemName = "\(baseName) \(counter)"
counter += 1 counter += 1
systemName = "New System \(counter)"
} }
let resolvedColorName = colorName ?? "blue"
let resolvedIconName = iconName ?? systemIconName(for: systemName)
let newSystem = ElectricalSystem( let newSystem = ElectricalSystem(
name: systemName, name: systemName,
location: "", location: "",
iconName: "building.2", iconName: resolvedIconName,
colorName: "blue" colorName: resolvedColorName
) )
modelContext.insert(newSystem) modelContext.insert(newSystem)
return 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) { private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem() let system = makeSystem()
let load = createLoad(from: item, in: system) let load = createLoad(from: item, in: system)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false) 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 { private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
let baseName = item.name.isEmpty ? "Library Load" : item.name let baseName = item.name.isEmpty ? "Library Load" : item.name
let loadName = uniqueLoadName(for: system, startingWith: baseName) let loadName = uniqueLoadName(for: system, startingWith: baseName)
@@ -383,7 +324,25 @@ struct SystemsView: View {
return "\(count) component\(count == 1 ? "" : "s")\(formattedPower) total" 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 { private func colorForName(_ colorName: String) -> Color {
switch colorName { switch colorName {
case "blue": return .blue case "blue": return .blue
@@ -1194,7 +1153,7 @@ private struct SystemBillOfMaterialsView: View {
case .affiliate(let url): case .affiliate(let url):
return url return url
case .amazonSearch(let query): 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) return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
} }
} }

View File

@@ -36,10 +36,10 @@ struct LoadEditorView: View {
} }
#Preview { #Preview {
@State var name = "My Load" @Previewable @State var name = "My Load"
@State var icon = "lightbulb" @Previewable @State var icon = "lightbulb"
@State var color = "blue" @Previewable @State var color = "blue"
@State var remoteIcon: String? = "https://example.com/icon.png" @Previewable @State var remoteIcon: String? = "https://example.com/icon.png"
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color, remoteIconURLString: $remoteIcon) return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color, remoteIconURLString: $remoteIcon)
} }

View File

@@ -35,7 +35,8 @@ struct LoadIconView: View {
} }
} }
.frame(width: size, height: size) .frame(width: size, height: size)
.onChange(of: remoteIconURLString) { _ in .onChange(of: remoteIconURLString) { oldValue, newValue in
guard oldValue != newValue else { return }
cachedImage = nil cachedImage = nil
hasAttemptedLoad = false hasAttemptedLoad = false
} }

View File

@@ -57,10 +57,10 @@ struct SystemEditorView: View {
} }
#Preview { #Preview {
@State var name = "My System" @Previewable @State var name = "My System"
@State var location = "Main Building" @Previewable @State var location = "Main Building"
@State var icon = "building.2" @Previewable @State var icon = "building.2"
@State var color = "blue" @Previewable @State var color = "blue"
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color) return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
} }

View File

@@ -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()
}
}
}