318 lines
9.9 KiB
Swift
Executable file
318 lines
9.9 KiB
Swift
Executable file
// Switch.swift
|
|
// iOS UI Components - Form Components
|
|
//
|
|
// Toggle switch with haptic feedback
|
|
|
|
import SwiftUI
|
|
import LilithDesignTokens
|
|
|
|
/// Toggle switch control
|
|
///
|
|
/// Switch control with label and haptic feedback.
|
|
///
|
|
/// Example:
|
|
/// ```swift
|
|
/// @State private var isEnabled = true
|
|
///
|
|
/// Switch(
|
|
/// "Enable notifications",
|
|
/// isOn: $isEnabled
|
|
/// )
|
|
/// ```
|
|
public struct Switch: View {
|
|
// MARK: - Properties
|
|
|
|
private let label: String?
|
|
@Binding private var isOn: Bool
|
|
private let isDisabled: Bool
|
|
private let onChange: ((Bool) -> Void)?
|
|
|
|
// MARK: - Initialization
|
|
|
|
/// Create a switch
|
|
/// - Parameters:
|
|
/// - label: Optional label text
|
|
/// - isOn: Binding to on/off state
|
|
/// - isDisabled: Disable interaction
|
|
/// - onChange: Callback when state changes
|
|
public init(
|
|
_ label: String? = nil,
|
|
isOn: Binding<Bool>,
|
|
isDisabled: Bool = false,
|
|
onChange: ((Bool) -> Void)? = nil
|
|
) {
|
|
self.label = label
|
|
self._isOn = isOn
|
|
self.isDisabled = isDisabled
|
|
self.onChange = onChange
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
public var body: some View {
|
|
Toggle(isOn: Binding(
|
|
get: { isOn },
|
|
set: { newValue in
|
|
isOn = newValue
|
|
handleChange(newValue)
|
|
}
|
|
)) {
|
|
if let label = label {
|
|
Text(label)
|
|
.font(AppTypography.body())
|
|
.foregroundColor(labelColor)
|
|
}
|
|
}
|
|
.toggleStyle(CustomSwitchStyle(isDisabled: isDisabled))
|
|
.disabled(isDisabled)
|
|
.accessibilityLabel(label ?? "Switch")
|
|
.accessibilityAddTraits(isOn ? [.isSelected] : [])
|
|
.accessibilityHint("Double tap to \(isOn ? "turn off" : "turn on")")
|
|
}
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var labelColor: Color {
|
|
isDisabled ? AppColors.textTertiary : AppColors.textPrimary
|
|
}
|
|
|
|
// MARK: - Methods
|
|
|
|
private func handleChange(_ newValue: Bool) {
|
|
guard !isDisabled else { return }
|
|
|
|
// Medium haptic feedback
|
|
#if canImport(UIKit)
|
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
|
generator.impactOccurred()
|
|
#endif
|
|
|
|
onChange?(newValue)
|
|
}
|
|
}
|
|
|
|
// MARK: - Custom Switch Style
|
|
|
|
struct CustomSwitchStyle: ToggleStyle {
|
|
let isDisabled: Bool
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
HStack {
|
|
configuration.label
|
|
|
|
Spacer()
|
|
|
|
// Switch track
|
|
ZStack(alignment: configuration.isOn ? .trailing : .leading) {
|
|
// Track background
|
|
Capsule()
|
|
.fill(trackColor(isOn: configuration.isOn))
|
|
.frame(width: 51, height: 31)
|
|
|
|
// Thumb
|
|
Circle()
|
|
.fill(.white)
|
|
.shadow(color: .black.opacity(0.15), radius: 2, x: 0, y: 2)
|
|
.frame(width: 27, height: 27)
|
|
.padding(2)
|
|
}
|
|
.onTapGesture {
|
|
if !isDisabled {
|
|
withAnimation(AppAnimations.fast) {
|
|
configuration.isOn.toggle()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func trackColor(isOn: Bool) -> Color {
|
|
if isDisabled {
|
|
return AppColors.Gray.gray700
|
|
}
|
|
return isOn ? AppColors.primary : AppColors.Gray.gray600
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview Provider
|
|
|
|
#if DEBUG
|
|
struct Switch_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: AppSpacing.xl) {
|
|
// Basic states
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Basic States")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Switch("Off", isOn: .constant(false))
|
|
Switch("On", isOn: .constant(true))
|
|
Switch("Disabled Off", isOn: .constant(false), isDisabled: true)
|
|
Switch("Disabled On", isOn: .constant(true), isDisabled: true)
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// Without labels
|
|
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
|
Text("Without Labels")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
HStack {
|
|
Switch(isOn: .constant(false))
|
|
Switch(isOn: .constant(true))
|
|
Switch(isOn: .constant(false), isDisabled: true)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// In context - Settings list
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("Notification Settings")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
.padding()
|
|
|
|
Group {
|
|
settingsRow(
|
|
title: "Push Notifications",
|
|
subtitle: "Receive notifications on this device",
|
|
isOn: true
|
|
)
|
|
|
|
Divider()
|
|
.padding(.leading)
|
|
|
|
settingsRow(
|
|
title: "Email Notifications",
|
|
subtitle: "Receive updates via email",
|
|
isOn: true
|
|
)
|
|
|
|
Divider()
|
|
.padding(.leading)
|
|
|
|
settingsRow(
|
|
title: "SMS Notifications",
|
|
subtitle: "Receive text message alerts",
|
|
isOn: false
|
|
)
|
|
|
|
Divider()
|
|
.padding(.leading)
|
|
|
|
settingsRow(
|
|
title: "Marketing Emails",
|
|
subtitle: "Receive product updates and offers",
|
|
isOn: false
|
|
)
|
|
}
|
|
}
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
|
|
Divider()
|
|
|
|
// In context - Privacy controls
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("Privacy Controls")
|
|
.font(AppTypography.h3())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
.padding()
|
|
|
|
Group {
|
|
privacyRow(
|
|
icon: "eye",
|
|
title: "Profile Visible",
|
|
description: "Allow others to see your profile",
|
|
isOn: true
|
|
)
|
|
|
|
Divider()
|
|
.padding(.leading, 56)
|
|
|
|
privacyRow(
|
|
icon: "location",
|
|
title: "Location Services",
|
|
description: "Share your approximate location",
|
|
isOn: false
|
|
)
|
|
|
|
Divider()
|
|
.padding(.leading, 56)
|
|
|
|
privacyRow(
|
|
icon: "hand.raised",
|
|
title: "Do Not Track",
|
|
description: "Opt out of analytics tracking",
|
|
isOn: true
|
|
)
|
|
}
|
|
}
|
|
.background(AppColors.surface)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.card))
|
|
}
|
|
.padding()
|
|
}
|
|
.background(AppColors.background)
|
|
.previewDisplayName("Switch States")
|
|
}
|
|
|
|
static func settingsRow(title: String, subtitle: String, isOn: Bool) -> some View {
|
|
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
|
Text(title)
|
|
.font(AppTypography.body())
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Text(subtitle)
|
|
.font(AppTypography.caption())
|
|
.foregroundColor(AppColors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Switch(isOn: .constant(isOn))
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
static func privacyRow(icon: String, title: String, description: String, isOn: Bool) -> some View {
|
|
HStack(spacing: AppSpacing.md) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20))
|
|
.foregroundColor(AppColors.primary)
|
|
.frame(width: 32, height: 32)
|
|
.background(AppColors.primary.opacity(0.1))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.sm))
|
|
|
|
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
|
Text(title)
|
|
.font(AppTypography.body(weight: .medium))
|
|
.foregroundColor(AppColors.textPrimary)
|
|
|
|
Text(description)
|
|
.font(AppTypography.caption())
|
|
.foregroundColor(AppColors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Switch(isOn: .constant(isOn))
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
#endif
|