graphically pleasing onboarding
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
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/>
|
||||
<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>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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