mirror of
https://github.com/home-assistant/iOS.git
synced 2026-06-18 11:15:36 -05:00
238 lines
9.1 KiB
Swift
238 lines
9.1 KiB
Swift
import Shared
|
||
import SwiftUI
|
||
|
||
/// A reusable screen scaffold for onboarding-style pages:
|
||
/// - Illustration at the top
|
||
/// - Big, centered title
|
||
/// - One or two body paragraphs
|
||
/// - Optional developer-injected content below the description
|
||
/// - Optional list of selectable choices (radio-style)
|
||
/// - Optional informational callout below choices
|
||
/// - Bottom area with primary button and optional secondary button
|
||
public struct BaseOnboardingView<Illustration: View, Content: View>: View, KeyboardReadable {
|
||
@State private var isKeyboardVisible = false
|
||
@Environment(\.disableOnboardingPrimaryAction) private var disablePrimaryAction
|
||
|
||
// MARK: - Inputs
|
||
|
||
private let illustration: () -> Illustration
|
||
private let title: String
|
||
private let primaryDescription: String
|
||
private let secondaryDescription: String?
|
||
|
||
// Optional injected content placed below the secondary description
|
||
private let content: (() -> Content)?
|
||
|
||
private let primaryActionTitle: String
|
||
private let primaryAction: () -> Void
|
||
|
||
private let secondaryActionTitle: String?
|
||
private let secondaryAction: (() -> Void)?
|
||
|
||
// Layout tuning
|
||
private let verticalSpacing: CGFloat
|
||
private let maxContentWidth: CGFloat = Sizes.maxWidthForLargerScreens
|
||
private let bottomAnchor = "bottom-anchor"
|
||
|
||
// MARK: - Inits
|
||
|
||
/// Iinitializer that accepts custom content below the descriptions.
|
||
public init(
|
||
@ViewBuilder illustration: @escaping () -> Illustration,
|
||
title: String,
|
||
primaryDescription: String,
|
||
secondaryDescription: String? = nil,
|
||
@ViewBuilder content: @escaping () -> Content,
|
||
primaryActionTitle: String,
|
||
primaryAction: @escaping () -> Void,
|
||
secondaryActionTitle: String? = nil,
|
||
secondaryAction: (() -> Void)? = nil,
|
||
illustrationTopPadding: CGFloat = DesignSystem.Spaces.four,
|
||
verticalSpacing: CGFloat = DesignSystem.Spaces.three
|
||
) {
|
||
self.illustration = illustration
|
||
self.title = title
|
||
self.primaryDescription = primaryDescription
|
||
self.secondaryDescription = secondaryDescription
|
||
self.content = content
|
||
self.primaryActionTitle = primaryActionTitle
|
||
self.primaryAction = primaryAction
|
||
self.secondaryActionTitle = secondaryActionTitle
|
||
self.secondaryAction = secondaryAction
|
||
self.verticalSpacing = verticalSpacing
|
||
}
|
||
|
||
/// No custom content initializer.
|
||
public init(
|
||
@ViewBuilder illustration: @escaping () -> Illustration,
|
||
title: String,
|
||
primaryDescription: String,
|
||
secondaryDescription: String? = nil,
|
||
primaryActionTitle: String,
|
||
primaryAction: @escaping () -> Void,
|
||
secondaryActionTitle: String? = nil,
|
||
secondaryAction: (() -> Void)? = nil,
|
||
illustrationTopPadding: CGFloat = DesignSystem.Spaces.four,
|
||
verticalSpacing: CGFloat = DesignSystem.Spaces.three
|
||
) where Content == EmptyView {
|
||
self.illustration = illustration
|
||
self.title = title
|
||
self.primaryDescription = primaryDescription
|
||
self.secondaryDescription = secondaryDescription
|
||
self.content = nil
|
||
self.primaryActionTitle = primaryActionTitle
|
||
self.primaryAction = primaryAction
|
||
self.secondaryActionTitle = secondaryActionTitle
|
||
self.secondaryAction = secondaryAction
|
||
self.verticalSpacing = verticalSpacing
|
||
}
|
||
|
||
// MARK: - Body
|
||
|
||
public var body: some View {
|
||
ScrollView {
|
||
ScrollViewReader { proxy in
|
||
VStack(spacing: verticalSpacing) {
|
||
Group {
|
||
if let image = illustration() as? Image {
|
||
image
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fit)
|
||
.frame(height: 130)
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
} else {
|
||
illustration()
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
}
|
||
}
|
||
.padding(.top, DesignSystem.Spaces.two)
|
||
|
||
Text(title)
|
||
.font(DesignSystem.Font.largeTitle.bold())
|
||
.multilineTextAlignment(.center)
|
||
.padding(.horizontal, DesignSystem.Spaces.two)
|
||
|
||
VStack(spacing: DesignSystem.Spaces.two) {
|
||
Text(primaryDescription)
|
||
.font(DesignSystem.Font.body)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
|
||
if let secondaryDescription {
|
||
Text(secondaryDescription)
|
||
.font(DesignSystem.Font.body)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
|
||
if let content {
|
||
content()
|
||
}
|
||
}
|
||
.padding(.horizontal, DesignSystem.Spaces.two)
|
||
|
||
Spacer(minLength: DesignSystem.Spaces.four)
|
||
|
||
// Bottom anchor to scroll when keyboard appears
|
||
Text("")
|
||
.frame(height: 100)
|
||
.id(bottomAnchor)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
.frame(maxWidth: maxContentWidth)
|
||
.onChange(of: isKeyboardVisible) { newValue in
|
||
if newValue {
|
||
proxy.scrollTo(bottomAnchor)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.safeAreaInset(edge: .bottom) {
|
||
bottomActions
|
||
}
|
||
.background(Color(uiColor: .systemBackground))
|
||
.onReceive(keyboardPublisher) { newIsKeyboardVisible in
|
||
isKeyboardVisible = newIsKeyboardVisible
|
||
}
|
||
}
|
||
|
||
// MARK: - Bottom actions
|
||
|
||
@ViewBuilder
|
||
private var bottomActions: some View {
|
||
VStack(spacing: DesignSystem.Spaces.one) {
|
||
Button(action: primaryAction) {
|
||
Text(primaryActionTitle)
|
||
}
|
||
.buttonStyle(.primaryButton)
|
||
.disabled(disablePrimaryAction)
|
||
|
||
if let secondaryActionTitle, let secondaryAction {
|
||
Button(action: secondaryAction) {
|
||
Text(secondaryActionTitle)
|
||
}
|
||
.buttonStyle(.secondaryButton)
|
||
.tint(Color.haPrimary)
|
||
}
|
||
}
|
||
.padding(.bottom, Current.isCatalyst ? DesignSystem.Spaces.two : .zero)
|
||
.frame(maxWidth: Sizes.maxWidthForLargerScreens)
|
||
.padding([.horizontal, .top], DesignSystem.Spaces.two)
|
||
.background(Color(uiColor: .systemBackground).opacity(0.95))
|
||
}
|
||
}
|
||
|
||
// MARK: - Previews
|
||
|
||
#Preview("Location permission example (simple)") {
|
||
NavigationView {
|
||
BaseOnboardingView(
|
||
illustration: {
|
||
Image(.Onboarding.world)
|
||
},
|
||
title: "Use this device's location for automations",
|
||
primaryDescription: "Location sharing enables powerful automations, such as turning off the heating when you leave home. This option shares the device’s location only with your Home Assistant system.",
|
||
secondaryDescription: "This data stays in your home and is never sent to third parties. It also helps strengthen the security of your connection to Home Assistant.",
|
||
primaryActionTitle: "Share my location",
|
||
primaryAction: {},
|
||
secondaryActionTitle: "Do not share my location",
|
||
secondaryAction: {}
|
||
)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|
||
|
||
#Preview("With injected content") {
|
||
NavigationView {
|
||
BaseOnboardingView(
|
||
illustration: {
|
||
Image(.Onboarding.world)
|
||
},
|
||
title: "Use this device's location for automations",
|
||
primaryDescription: "Location sharing enables powerful automations.",
|
||
secondaryDescription: "This data stays in your home and is never sent to third parties.",
|
||
content: {
|
||
VStack(spacing: DesignSystem.Spaces.one) {
|
||
Toggle(isOn: .constant(true)) {
|
||
Text("Also share precise location")
|
||
}
|
||
.toggleStyle(.switch)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
|
||
Text("You can change this later in Settings.")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxWidth: DesignSystem.List.rowMaxWidth)
|
||
},
|
||
primaryActionTitle: "Share my location",
|
||
primaryAction: {},
|
||
secondaryActionTitle: "Do not share my location",
|
||
secondaryAction: {}
|
||
)
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
}
|
||
}
|