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 = {
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;
};

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/>
<key>com.apple.developer.icloud-services</key>
<array/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

View File

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

View File

@@ -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? {

View File

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

View File

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

View File

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

View File

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

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