graphically pleasing onboarding
This commit is contained in:
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
21
Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json
vendored
Normal file
21
Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Cable/Assets.xcassets/boat-onboarding.imageset/boat-ob.png
vendored
Normal file
BIN
Cable/Assets.xcassets/boat-onboarding.imageset/boat-ob.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
21
Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json
vendored
Normal file
21
Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-ob.png
vendored
Normal file
BIN
Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-ob.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 391 KiB |
21
Cable/Assets.xcassets/van-onboarding.imageset/Contents.json
vendored
Normal file
21
Cable/Assets.xcassets/van-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Cable/Assets.xcassets/van-onboarding.imageset/van-ob.png
vendored
Normal file
BIN
Cable/Assets.xcassets/van-onboarding.imageset/van-ob.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 415 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
141
Cable/SystemsOnboardingView.swift
Normal file
141
Cable/SystemsOnboardingView.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user