diff --git a/core/Sources/Components/Slider/AccessibilityIdentifiier/SliderAccessibilityIdentifier.swift b/core/Sources/Components/Slider/AccessibilityIdentifiier/SliderAccessibilityIdentifier.swift new file mode 100644 index 000000000..15671d89d --- /dev/null +++ b/core/Sources/Components/Slider/AccessibilityIdentifiier/SliderAccessibilityIdentifier.swift @@ -0,0 +1,18 @@ +// +// SliderAccessibilityIdentifier.swift +// SparkCore +// +// Created by louis.borlee on 12.12.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +/// The accessibility identifiers for the slider. +public enum SliderAccessibilityIdentifier { + + // MARK: - Properties + + /// The text label accessibility identifier. + public static let slider = "spark-slider" +} diff --git a/core/Sources/Components/Slider/Handle/View/SliderHandleUIControl.swift b/core/Sources/Components/Slider/Handle/View/SliderHandleUIControl.swift new file mode 100644 index 000000000..eb6b0d467 --- /dev/null +++ b/core/Sources/Components/Slider/Handle/View/SliderHandleUIControl.swift @@ -0,0 +1,105 @@ +// +// SliderHandleUIControl.swift +// SparkCore +// +// Created by louis.borlee on 23/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import UIKit +import Combine + +final class SliderHandleUIControl: UIControl { + + private let handleView = UIView() + private let activeIndicatorView = UIView() + + private var cancellables = Set() + + let viewModel: SliderHandleViewModel + + override var isHighlighted: Bool { + didSet { + self.activeIndicatorView.isHidden = !self.isHighlighted + } + } + + init(viewModel: SliderHandleViewModel) { + self.viewModel = viewModel + super.init(frame: .init( + origin: .zero, + size: .init( + width: SliderConstants.handleSize.width, + height: SliderConstants.handleSize.height + ) + )) + self.setupHandleView() + self.setupActiveHandleView() + self.subscribeToViewModelChanges() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupHandleView() { + self.handleView.frame = self.bounds + self.handleView.isUserInteractionEnabled = false + self.handleView.layer.cornerRadius = SliderConstants.handleSize.height / 2 + self.addSubview(self.handleView) + } + + private func setupActiveHandleView() { + self.activeIndicatorView.setBorderWidth(1.0) + self.activeIndicatorView.isUserInteractionEnabled = false + self.activeIndicatorView.isHidden = true + self.activeIndicatorView.layer.cornerRadius = SliderConstants.activeIndicatorSize.height / 2 + + self.activeIndicatorView.frame.size = .init(width: SliderConstants.activeIndicatorSize.width, height: SliderConstants.activeIndicatorSize.height) + self.activeIndicatorView.center = self.handleView.center + + self.insertSubview(self.activeIndicatorView, belowSubview: self.handleView) + } + + private func subscribeToViewModelChanges() { + self.viewModel.$color.subscribe(in: &self.cancellables) { [weak self] newColor in + guard let self else { return } + self.handleView.backgroundColor = newColor.uiColor + self.activeIndicatorView.setBorderColor(from: newColor) + } + self.viewModel.$activeIndicatorColor.subscribe(in: &self.cancellables) { [weak self] newActiveIndicatorColor in + guard let self else { return } + self.activeIndicatorView.backgroundColor = newActiveIndicatorColor.uiColor + } + } + + override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let beginTracking = super.beginTracking(touch, with: event) + if let supercontrol = superview as? UIControl { + return supercontrol.beginTracking(touch, with: event) + } + return beginTracking + } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let continueTracking = super.continueTracking(touch, with: event) + if let supercontrol = superview as? UIControl { + return supercontrol.continueTracking(touch, with: event) + } + return continueTracking + } + + override func cancelTracking(with event: UIEvent?) { + super.cancelTracking(with: event) + if let supercontrol = superview as? UIControl { + supercontrol.cancelTracking(with: event) + } + } + + override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + if let supercontrol = superview as? UIControl { + supercontrol.endTracking(touch, with: event) + } + } +} diff --git a/core/Sources/Components/Slider/Handle/ViewModel/SliderHandleViewModel.swift b/core/Sources/Components/Slider/Handle/ViewModel/SliderHandleViewModel.swift new file mode 100644 index 000000000..a157848da --- /dev/null +++ b/core/Sources/Components/Slider/Handle/ViewModel/SliderHandleViewModel.swift @@ -0,0 +1,21 @@ +// +// SliderHandleViewModel.swift +// SparkCore +// +// Created by louis.borlee on 23/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +final class SliderHandleViewModel { + + @Published var color: any ColorToken + @Published var activeIndicatorColor: any ColorToken + + init(color: some ColorToken, + activeIndicatorColor: some ColorToken) { + self.color = color + self.activeIndicatorColor = activeIndicatorColor + } +} diff --git a/core/Sources/Components/Slider/Properties/Private/SliderColors+ExtensionTests.swift b/core/Sources/Components/Slider/Properties/Private/SliderColors+ExtensionTests.swift new file mode 100644 index 000000000..ce771f4ab --- /dev/null +++ b/core/Sources/Components/Slider/Properties/Private/SliderColors+ExtensionTests.swift @@ -0,0 +1,21 @@ +// +// SliderColors+ExtensionTests.swift +// SparkCore +// +// Created by louis.borlee on 08/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension SliderColors { + static func mocked(colors: Colors) -> SliderColors { + return .init( + track: colors.feedback.alert, + indicator: colors.accent.accentVariant, + handle: colors.states.neutralPressed, + handleActiveIndicator: colors.basic.onBasicContainer + ) + } +} diff --git a/core/Sources/Components/Slider/Properties/Private/SliderRadii+ExtensionTests.swift b/core/Sources/Components/Slider/Properties/Private/SliderRadii+ExtensionTests.swift new file mode 100644 index 000000000..d0ee82b08 --- /dev/null +++ b/core/Sources/Components/Slider/Properties/Private/SliderRadii+ExtensionTests.swift @@ -0,0 +1,19 @@ +// +// SliderRadii+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 08/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension SliderRadii { + static func mocked() -> SliderRadii { + return .init( + trackRadius: 0.123, + indicatorRadius: 49.3 + ) + } +} diff --git a/core/Sources/Components/Slider/UseCase/CreateValuesFromSteps/SliderCreateStepsUseCase.swift b/core/Sources/Components/Slider/UseCase/CreateValuesFromSteps/SliderCreateStepsUseCase.swift index 6a2241547..6332694ce 100644 --- a/core/Sources/Components/Slider/UseCase/CreateValuesFromSteps/SliderCreateStepsUseCase.swift +++ b/core/Sources/Components/Slider/UseCase/CreateValuesFromSteps/SliderCreateStepsUseCase.swift @@ -15,13 +15,13 @@ enum SliderCreateValuesFromStepsUseCasableError: Error { // sourcery: AutoMockable protocol SliderCreateValuesFromStepsUseCasable { - func execute(from: CGFloat, - to: CGFloat, - steps: CGFloat) throws -> [CGFloat] + func execute(from: Float, + to: Float, + steps: Float) throws -> [Float] } final class SliderCreateValuesFromStepsUseCase: SliderCreateValuesFromStepsUseCasable { - func execute(from: CGFloat, to: CGFloat, steps: CGFloat) throws -> [CGFloat] { + func execute(from: Float, to: Float, steps: Float) throws -> [Float] { guard from < to else { throw SliderCreateValuesFromStepsUseCasableError.invalidRange } guard steps > .zero, steps <= (to - from) else { throw SliderCreateValuesFromStepsUseCasableError.invalidStep } diff --git a/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCase.swift b/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCase.swift index 592e036ee..ad5c4937f 100644 --- a/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCase.swift +++ b/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCase.swift @@ -10,7 +10,7 @@ import Foundation // sourcery: AutoMockable protocol SliderGetClosestValueUseCasable { - func execute(from: CGFloat, to: CGFloat, withSteps steps: CGFloat, fromValue value: CGFloat) -> CGFloat + func execute(from: Float, to: Float, withSteps steps: Float, fromValue value: Float) -> Float } final class SliderGetClosestValueUseCase: SliderGetClosestValueUseCasable { @@ -21,7 +21,7 @@ final class SliderGetClosestValueUseCase: SliderGetClosestValueUseCasable { } - func execute(from: CGFloat, to: CGFloat, withSteps steps: CGFloat, fromValue value: CGFloat) -> CGFloat { + func execute(from: Float, to: Float, withSteps steps: Float, fromValue value: Float) -> Float { do { let values = try self.createValuesFromStepsUseCase.execute( from: from, diff --git a/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCaseTests.swift b/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCaseTests.swift index a751ff23e..36965268e 100644 --- a/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCaseTests.swift +++ b/core/Sources/Components/Slider/UseCase/GetClosestValue/SliderGetClosestValueUseCaseTests.swift @@ -14,7 +14,7 @@ final class SliderGetClosestValueUseCaseTests: XCTestCase { func test_execute_createValuesFromStep_receivedValues() throws { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in return [1, 2, 3] } let sut = SliderGetClosestValueUseCase(createValuesFromStepsUseCase: createValuesFromStepUseCaseMock) @@ -35,7 +35,7 @@ final class SliderGetClosestValueUseCaseTests: XCTestCase { func test_execute_createValuesFromStep_throws_invalidRange() { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in throw SliderCreateValuesFromStepsUseCasableError.invalidRange } let sut = SliderGetClosestValueUseCase(createValuesFromStepsUseCase: createValuesFromStepUseCaseMock) @@ -50,7 +50,7 @@ final class SliderGetClosestValueUseCaseTests: XCTestCase { func test_execute_createValuesFromStep_throws_invalid() { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in throw SliderCreateValuesFromStepsUseCasableError.invalidStep } let sut = SliderGetClosestValueUseCase(createValuesFromStepsUseCase: createValuesFromStepUseCaseMock) @@ -65,7 +65,7 @@ final class SliderGetClosestValueUseCaseTests: XCTestCase { func test_execute_createValuesFromStep_empty_values() { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in return [] } let sut = SliderGetClosestValueUseCase(createValuesFromStepsUseCase: createValuesFromStepUseCaseMock) @@ -80,29 +80,29 @@ final class SliderGetClosestValueUseCaseTests: XCTestCase { func test_execute_inbetween() { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in return [ - 0.0, - 0.1, - 0.2, - 0.3, - 0.4, - 0.5 + 0, + 10, + 20, + 30, + 40, + 50 ] } let sut = SliderGetClosestValueUseCase(createValuesFromStepsUseCase: createValuesFromStepUseCaseMock) // WHEN - let value = sut.execute(from: 0, to: 0.5, withSteps: 0.1, fromValue: 0.25) + let value = sut.execute(from: 0, to: 50, withSteps: 0.1, fromValue: 25) // THEN - XCTAssertEqual(value, 0.3) + XCTAssertEqual(value, 30) } func test_execute_lower() { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in return [ 0.0, 0.5 @@ -120,7 +120,7 @@ final class SliderGetClosestValueUseCaseTests: XCTestCase { func test_execute_upper() { // GIVEN let createValuesFromStepUseCaseMock = SliderCreateValuesFromStepsUseCasableGeneratedMock() - createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [CGFloat] in + createValuesFromStepUseCaseMock._executeWithFromAndToAndSteps = { _, _ , _ throws -> [Float] in return [ 0.0, 0.1, diff --git a/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCasableGeneratedMock+ExtensionTests.swift b/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCasableGeneratedMock+ExtensionTests.swift new file mode 100644 index 000000000..6ee5c3f16 --- /dev/null +++ b/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCasableGeneratedMock+ExtensionTests.swift @@ -0,0 +1,20 @@ +// +// SliderGetColorsUseCasableGeneratedMock+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 06/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension SliderGetColorsUseCasableGeneratedMock { + static func mocked(returnedColors colors: SliderColors) -> SliderGetColorsUseCasableGeneratedMock { + let mock = SliderGetColorsUseCasableGeneratedMock() + mock._executeWithThemeAndIntent = { _, _ in + return colors + } + return mock + } +} diff --git a/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCase.swift b/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCase.swift index d30777c65..cc3be0970 100644 --- a/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCase.swift +++ b/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCase.swift @@ -11,18 +11,16 @@ import UIKit // sourcery: AutoMockable protocol SliderGetColorsUseCasable { func execute(theme: Theme, - intent: SliderIntent, - isEnabled: Bool) -> SliderColors + intent: SliderIntent) -> SliderColors } final class SliderGetColorsUseCase: SliderGetColorsUseCasable { func execute(theme: Theme, - intent: SliderIntent, - isEnabled: Bool) -> SliderColors { + intent: SliderIntent) -> SliderColors { let colors = theme.colors let dims = theme.dims - var sliderColors: SliderColors + let sliderColors: SliderColors let trackColor = colors.base.onBackground.opacity(dims.dim4) switch intent { case .basic: @@ -89,9 +87,6 @@ final class SliderGetColorsUseCase: SliderGetColorsUseCasable { handleActiveIndicator: colors.feedback.infoContainer ) } - if isEnabled == false { - sliderColors = sliderColors.withOpacity(dims.dim3) - } return sliderColors } } diff --git a/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCaseTests.swift b/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCaseTests.swift index 10ec9a898..77517430a 100644 --- a/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCaseTests.swift +++ b/core/Sources/Components/Slider/UseCase/GetColors/SliderGetColorsUseCaseTests.swift @@ -32,27 +32,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .basic, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_basic_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.basic.basic.opacity(self.dims.dim3), - handle: self.colors.basic.basic.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.basic.basicContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .basic, - isEnabled: false) + intent: .basic) // THEN XCTAssertEqual(colors, expectedColors) @@ -71,27 +51,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .success, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_success_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.feedback.success.opacity(self.dims.dim3), - handle: self.colors.feedback.success.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.feedback.successContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .success, - isEnabled: false) + intent: .success) // THEN XCTAssertEqual(colors, expectedColors) @@ -110,27 +70,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .error, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_error_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.feedback.error.opacity(self.dims.dim3), - handle: self.colors.feedback.error.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.feedback.errorContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .error, - isEnabled: false) + intent: .error) // THEN XCTAssertEqual(colors, expectedColors) @@ -149,27 +89,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .alert, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_alert_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.feedback.alert.opacity(self.dims.dim3), - handle: self.colors.feedback.alert.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.feedback.alertContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .alert, - isEnabled: false) + intent: .alert) // THEN XCTAssertEqual(colors, expectedColors) @@ -188,27 +108,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .accent, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_accent_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.accent.accent.opacity(self.dims.dim3), - handle: self.colors.accent.accent.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.accent.accentContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .accent, - isEnabled: false) + intent: .accent) // THEN XCTAssertEqual(colors, expectedColors) @@ -227,27 +127,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .main, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_main_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.main.main.opacity(self.dims.dim3), - handle: self.colors.main.main.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.main.mainContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .main, - isEnabled: false) + intent: .main) // THEN XCTAssertEqual(colors, expectedColors) @@ -266,27 +146,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .neutral, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_neutral_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.feedback.neutral.opacity(self.dims.dim3), - handle: self.colors.feedback.neutral.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.feedback.neutralContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .neutral, - isEnabled: false) + intent: .neutral) // THEN XCTAssertEqual(colors, expectedColors) @@ -305,27 +165,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .support, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_support_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.support.support.opacity(self.dims.dim3), - handle: self.colors.support.support.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.support.supportContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .support, - isEnabled: false) + intent: .support) // THEN XCTAssertEqual(colors, expectedColors) @@ -344,27 +184,7 @@ final class SliderGetColorsUseCaseTests: XCTestCase { // WHEN let colors = useCase.execute(theme: self.theme, - intent: .info, - isEnabled: true) - - // THEN - XCTAssertEqual(colors, expectedColors) - } - - func test_execute_intent_info_disabled() { - // GIVEN - let useCase = SliderGetColorsUseCase() - let expectedColors = SliderColors( - track: self.colors.base.onBackground.opacity(self.dims.dim4).opacity(self.dims.dim3), - indicator: self.colors.feedback.info.opacity(self.dims.dim3), - handle: self.colors.feedback.info.opacity(self.dims.dim3), - handleActiveIndicator: self.colors.feedback.infoContainer.opacity(self.dims.dim3) - ) - - // WHEN - let colors = useCase.execute(theme: self.theme, - intent: .info, - isEnabled: false) + intent: .info) // THEN XCTAssertEqual(colors, expectedColors) diff --git a/core/Sources/Components/Slider/UseCase/GetCornerRadii/SliderGetCornerRadiiUseCasableGeneratedMock+ExtensionTests.swift b/core/Sources/Components/Slider/UseCase/GetCornerRadii/SliderGetCornerRadiiUseCasableGeneratedMock+ExtensionTests.swift new file mode 100644 index 000000000..ac8530522 --- /dev/null +++ b/core/Sources/Components/Slider/UseCase/GetCornerRadii/SliderGetCornerRadiiUseCasableGeneratedMock+ExtensionTests.swift @@ -0,0 +1,20 @@ +// +// SliderGetCornerRadiiUseCasableGeneratedMock+ExtensionTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 06/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +@testable import SparkCore + +extension SliderGetCornerRadiiUseCasableGeneratedMock { + static func mocked(expectedRadii radii: SliderRadii) -> SliderGetCornerRadiiUseCasableGeneratedMock { + let mock = SliderGetCornerRadiiUseCasableGeneratedMock() + mock._executeWithThemeAndShape = { _, _ in + return radii + } + return mock + } +} diff --git a/core/Sources/Components/Slider/View/SliderUIControl.swift b/core/Sources/Components/Slider/View/SliderUIControl.swift new file mode 100644 index 000000000..99e7bf041 --- /dev/null +++ b/core/Sources/Components/Slider/View/SliderUIControl.swift @@ -0,0 +1,274 @@ +// +// SliderUIControl.swift +// SparkCore +// +// Created by louis.borlee on 24/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import UIKit +import Combine + +public final class SliderUIControl: UIControl { + + private let viewModel: SingleSliderViewModel + private var cancellables = Set() + private var _isTracking: Bool = false + + // MARK: - Public properties + /// The slider's current theme. + public var theme: Theme { + get { return self.viewModel.theme } + set { self.viewModel.theme = newValue } + } + + /// The slider's current intent. + public var intent: SliderIntent { + get { return self.viewModel.intent } + set { self.viewModel.intent = newValue } + } + + /// The slider's current shape (`square` or `rounded`). + public var shape: SliderShape { + get { return self.viewModel.shape } + set { self.viewModel.shape = newValue } + } + + /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. + public var isContinuous: Bool { + get { return self.viewModel.isContinuous } + set { self.viewModel.isContinuous = newValue } + } + + /// The minimum value of the slider. + public var minimumValue: Float { + get { return self.viewModel.minimumValue } + set { + self.viewModel.minimumValue = min(self.maximumValue, newValue) + self.resetValue() + } + } + + /// The maximum value of the slider. + public var maximumValue: Float { + get { return self.viewModel.maximumValue } + set { + self.viewModel.maximumValue = max(self.minimumValue, newValue) + self.resetValue() + } + } + + /// The distance between each valid value. + public var steps: Float { + get { return self.viewModel.steps } + set { self.viewModel.steps = newValue } + } + + public override var isEnabled: Bool { + didSet { + self.viewModel.isEnabled = self.isEnabled + self.isUserInteractionEnabled = self.isEnabled + } + } + + /// The slider’s current value. + public private(set) var value: Float = .zero { + didSet { + switch (self._isTracking, self.isContinuous) { + case (false, _): + break // valueChanged event should only trigger when isTracking is true same as UISlider + case (true, false): // valueChanged event should not be sent while tracking when isContinuous is false + break + case (true, true): + self.sendActions(for: .valueChanged) + } + self.setNeedsLayout() + } + } + + public override var isHighlighted: Bool { + didSet { + self.handle.isHighlighted = self.isHighlighted + } + } + + private var valueSubject = PassthroughSubject() + /// Value changes are sent to the publisher. + /// Alternative: use addAction(UIAction, for: .valueChanged). + public var valuePublisher: some Publisher { + return self.valueSubject + } + + // MARK: - Subviews + private let indicatorView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: SliderConstants.barHeight))) + private let trackView = UIView(frame: CGRect(origin: .zero, size: CGSize(width: 0, height: SliderConstants.barHeight))) + private let handle: SliderHandleUIControl + + init(viewModel: SingleSliderViewModel) { + self.viewModel = viewModel + self.handle = SliderHandleUIControl( + viewModel: .init(color: viewModel.handleColor, + activeIndicatorColor: viewModel.handleActiveIndicatorColor) + ) + super.init(frame: .init(origin: .zero, size: .init(width: 0, height: SliderConstants.handleSize.height))) + self.alpha = self.viewModel.dim + self.setupBar() + self.subscribeToViewModel() + self.translatesAutoresizingMaskIntoConstraints = false + let defaultHeightConstraint = self.heightAnchor.constraint(equalToConstant: SliderConstants.handleSize.height) + defaultHeightConstraint.priority = .defaultHigh + NSLayoutConstraint.activate([ + defaultHeightConstraint + ]) + self.addSubview(self.handle) + self.addAction(UIAction(handler: { [weak self] _ in + guard let self else { return } + self.valueSubject.send(self.value) + }), for: .valueChanged) + self.accessibilityIdentifier = SliderAccessibilityIdentifier.slider + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// SliderUIControler initializer + /// - Parameters: + /// - theme: The slider's current theme + /// - shape: The slider's current shape (`square` or `rounded`) + /// - intent: The slider's current intent + public convenience init( + theme: Theme, + shape: SliderShape, + intent: SliderIntent + ) { + self.init(viewModel: .init(theme: theme, shape: shape, intent: intent)) + } + + public override func layoutSubviews() { + super.layoutSubviews() + + self.indicatorView.center.y = self.frame.height / 2 + self.handle.center.y = self.frame.height / 2 + self.trackView.center.y = self.frame.height / 2 + + if self.minimumValue != self.maximumValue { + let value = (max(self.minimumValue, self.value) - self.minimumValue) / (self.maximumValue - self.minimumValue) + self.handle.center.x = (self.frame.width - SliderConstants.handleSize.width) * CGFloat(value) + SliderConstants.handleSize.width / 2 + } else { + self.handle.center.x = SliderConstants.handleSize.width / 2 + } + + self.indicatorView.frame.size.width = self.handle.center.x + + self.trackView.frame.origin.x = self.handle.center.x + self.trackView.frame.size.width = self.frame.width - self.trackView.frame.origin.x + } + + /// Sets the slider’s current value, allowing you to animate the change visually. + /// - Parameters: + /// - value: The new value to assign to the value property + /// - animated: Specify `true` to animate the change in value; otherwise, specify `false` to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. + public func setValue(_ value: Float, animated: Bool = false) { + if animated { + UIView.animate(withDuration: 0.3) { [weak self] in + guard let self else { return } + self.viewModel.setAbsoluteValue(value) + self.layoutSubviews() + } + } else { + self.viewModel.setAbsoluteValue(value) + } + } + + private func resetValue() { + self.viewModel.setAbsoluteValue(self.value) + } + + private func setupBar() { + self.indicatorView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner] // Left + self.addSubview(self.indicatorView) + + self.trackView.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMaxXMaxYCorner] // Right + self.addSubview(self.trackView) + } + + private func subscribeToViewModel() { + // Indicator + self.viewModel.$indicatorColor.subscribe(in: &self.cancellables) { [weak self] newIndicatorColor in + self?.indicatorView.backgroundColor = newIndicatorColor.uiColor + } + self.viewModel.$indicatorRadius.subscribe(in: &self.cancellables) { [weak self] newIndicatorRadius in + self?.indicatorView.layer.cornerRadius = newIndicatorRadius / 2.0 // "/ 2.0" for top / bottom + } + + // Track + self.viewModel.$trackColor.subscribe(in: &self.cancellables) { [weak self] newTrackColor in + self?.trackView.backgroundColor = newTrackColor.uiColor + } + self.viewModel.$trackRadius.subscribe(in: &self.cancellables) { [weak self] newTrackRadius in + self?.trackView.layer.cornerRadius = newTrackRadius / 2.0 // "/ 2.0" for top / bottom + } + + // Handle + self.viewModel.$handleColor.subscribe(in: &self.cancellables) { [weak self] newHandleColor in + self?.handle.viewModel.color = newHandleColor + } + + self.viewModel.$handleActiveIndicatorColor.subscribe(in: &self.cancellables) { [weak self] newHandleActiveIndicatorColor in + self?.handle.viewModel.activeIndicatorColor = newHandleActiveIndicatorColor + } + + // Value + self.viewModel.$value.subscribe(in: &self.cancellables) { [weak self] newValue in + guard let self, + self.value != newValue else { return } + self.value = newValue + } + + // Dim + self.viewModel.$dim.subscribe(in: &self.cancellables) { [weak self] newDim in + self?.alpha = newDim + } + } + + // MARK: - Tracking + public override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + super.beginTracking(touch, with: event) + self._isTracking = true + let location = touch.location(in: self) + self.isHighlighted = true + self.moveHandle(to: location.x) + return true + } + + public override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let continueTracking = super.continueTracking(touch, with: event) + + let location = touch.location(in: self) + + self.moveHandle(to: location.x) + + return continueTracking + } + + public override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + super.endTracking(touch, with: event) + if self._isTracking, + self.isContinuous == false { + self.sendActions(for: .valueChanged) + } + self._isTracking = false + self.isHighlighted = false + } + + private func moveHandle(to: CGFloat) { + let absoluteX = max(SliderConstants.handleSize.width / 2, min(to, self.frame.width - SliderConstants.handleSize.width / 2)) + let relativeX = (absoluteX - SliderConstants.handleSize.width / 2) / (self.frame.width - SliderConstants.handleSize.width) + + self.setValue( + Float(relativeX) * (self.viewModel.maximumValue - self.viewModel.minimumValue) + self.viewModel.minimumValue, + animated: false + ) + } +} diff --git a/core/Sources/Components/Slider/ViewModel/Base/SliderViewModel.swift b/core/Sources/Components/Slider/ViewModel/Base/SliderViewModel.swift new file mode 100644 index 000000000..b0effee0f --- /dev/null +++ b/core/Sources/Components/Slider/ViewModel/Base/SliderViewModel.swift @@ -0,0 +1,126 @@ +// +// SliderViewModel.swift +// SparkCore +// +// Created by louis.borlee on 23/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +class SliderViewModel { + + // MARK: - Private Properties + private let getColorsUseCase: SliderGetColorsUseCasable + private let getCornerRadiiUseCase: SliderGetCornerRadiiUseCasable + private let getClosestValueUseCase: SliderGetClosestValueUseCasable + + // MARK: - Internal Properties + var theme: Theme { + didSet { + self.setDim() + self.setColors() + self.setRadii() + } + } + var shape: SliderShape { + didSet { + guard oldValue != self.shape else { return } + self.setRadii() + } + } + var intent: SliderIntent { + didSet { + guard oldValue != self.intent else { return } + self.setColors() + } + } + + var isEnabled = true { + didSet { + guard oldValue != self.isEnabled else { return } + self.setDim() + } + } + var isContinuous = true + + var steps: Float = 0.0 + + // MARK: - Published Colors + @Published var trackColor: any ColorToken + @Published var indicatorColor: any ColorToken + @Published var handleColor: any ColorToken + @Published var handleActiveIndicatorColor: any ColorToken + + // MARK: - Published Radii + @Published var trackRadius: CGFloat + @Published var indicatorRadius: CGFloat + + // MARK: - Published Values + @Published var minimumValue: Float = .zero + @Published var maximumValue: Float = 1.0 + + // MARK: - Published Dim + @Published var dim: CGFloat + + required init(theme: Theme, + shape: SliderShape, + intent: SliderIntent, + getColorsUseCase: SliderGetColorsUseCasable = SliderGetColorsUseCase(), + getCornerRadiiUseCase: SliderGetCornerRadiiUseCasable = SliderGetCornerRadiiUseCase(), + getClosestValueUseCase: SliderGetClosestValueUseCasable = SliderGetClosestValueUseCase()) { + self.theme = theme + self.shape = shape + self.intent = intent + self.getColorsUseCase = getColorsUseCase + self.getCornerRadiiUseCase = getCornerRadiiUseCase + self.getClosestValueUseCase = getClosestValueUseCase + + self.dim = self.theme.dims.none + + let colors = getColorsUseCase.execute(theme: self.theme, intent: self.intent) + self.trackColor = colors.track + self.indicatorColor = colors.indicator + self.handleColor = colors.handle + self.handleActiveIndicatorColor = colors.handleActiveIndicator + + let radii = getCornerRadiiUseCase.execute(theme: self.theme, shape: self.shape) + self.trackRadius = radii.trackRadius + self.indicatorRadius = radii.indicatorRadius + } + + private func setDim() { + self.dim = self.isEnabled ? self.theme.dims.none : self.theme.dims.dim3 + } + + private func setColors() { + let colors = self.getColorsUseCase.execute(theme: self.theme, intent: self.intent) + self.trackColor = colors.track + self.indicatorColor = colors.indicator + self.handleColor = colors.handle + self.handleActiveIndicatorColor = colors.handleActiveIndicator + } + + private func setRadii() { + let radii = getCornerRadiiUseCase.execute(theme: self.theme, shape: self.shape) + self.trackRadius = radii.trackRadius + self.indicatorRadius = radii.indicatorRadius + } + + func getClosestValue(fromValue value: Float) -> Float { + return self.getClosestValueUseCase.execute( + from: self.minimumValue, + to: self.maximumValue, + withSteps: self.steps, + fromValue: value + ) + } +} + +final class RangeSliderViewModel: SliderViewModel { + + // MARK: - Published values + @Published var from: CGFloat = .zero + @Published var to: CGFloat = 1.0 + +} diff --git a/core/Sources/Components/Slider/ViewModel/Base/SliderViewModelTests.swift b/core/Sources/Components/Slider/ViewModel/Base/SliderViewModelTests.swift new file mode 100644 index 000000000..2f5707a5a --- /dev/null +++ b/core/Sources/Components/Slider/ViewModel/Base/SliderViewModelTests.swift @@ -0,0 +1,394 @@ +// +// SliderViewModelTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 06/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest +import Combine +@testable import SparkCore + +class SliderViewModelTests: XCTestCase { + private let intent = SliderIntent.info + private let shape = SliderShape.rounded + private let expectedRadii = SliderRadii.mocked() + + private var theme: ThemeGeneratedMock! + var viewModel: T! + private var expectedColors: SliderColors! + var getColorsUseCase: SliderGetColorsUseCasableGeneratedMock! + var getCornerRadiiUseCase: SliderGetCornerRadiiUseCasableGeneratedMock! + var getClosestValueUseCase: SliderGetClosestValueUseCasableGeneratedMock! + var publishers: SliderPublishers! + + override func setUp() { + super.setUp() + self.theme = ThemeGeneratedMock.mocked() + self.expectedColors = SliderColors.mocked(colors: self.theme.colors) + self.getColorsUseCase = SliderGetColorsUseCasableGeneratedMock.mocked(returnedColors: self.expectedColors) + self.getCornerRadiiUseCase = SliderGetCornerRadiiUseCasableGeneratedMock.mocked(expectedRadii: self.expectedRadii) + self.getClosestValueUseCase = SliderGetClosestValueUseCasableGeneratedMock() + self.viewModel = T( + theme: self.theme, + shape: self.shape, + intent: self.intent, + getColorsUseCase: self.getColorsUseCase, + getCornerRadiiUseCase: self.getCornerRadiiUseCase, + getClosestValueUseCase: self.getClosestValueUseCase + ) + self.setupPublishers() + } + + func setupPublishers() { + self.publishers = SliderPublishers( + dim: PublisherMock(publisher: self.viewModel.$dim), + trackColor: PublisherMock(publisher: self.viewModel.$trackColor), + handleColor: PublisherMock(publisher: self.viewModel.$handleColor), + indicatorColor: PublisherMock(publisher: self.viewModel.$indicatorColor), + handleActiveIndicatorColor: PublisherMock(publisher: self.viewModel.$handleActiveIndicatorColor), + trackRadius: PublisherMock(publisher: self.viewModel.$trackRadius), + indicatorRadius: PublisherMock(publisher: self.viewModel.$indicatorRadius) + ) + self.publishers.load() + } + + // MARK: - init + func test_init() throws { + // GIVEN / WHEN - Inits from setUp() + // THEN - Simple variables + XCTAssertIdentical(self.viewModel.theme as? ThemeGeneratedMock, self.theme, "Wrong theme") + XCTAssertEqual(self.viewModel.intent, self.intent, "Wrong theme") + XCTAssertEqual(self.viewModel.shape, self.shape, "Wrong shape") + XCTAssertEqual(self.viewModel.dim, self.theme.dims.none, "Wrong dim") + + // THEN - Corner Radii + XCTAssertEqual(self.getCornerRadiiUseCase.executeWithThemeAndShapeCallsCount, 1, "getCornerRadiiUseCase.executeWithThemeAndShape should be called once") + let radiiReceivedArguments = try XCTUnwrap(self.getCornerRadiiUseCase.executeWithThemeAndShapeReceivedArguments, "Couldn't unwrap radiiReceivedArguments") + XCTAssertEqual(radiiReceivedArguments.shape, self.shape, "Wrong radiiReceivedArguments.shape") + XCTAssertIdentical(radiiReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong radiiReceivedArguments.theme") + XCTAssertEqual(self.viewModel.trackRadius, self.expectedRadii.trackRadius, "Wrong trackRadius") + XCTAssertEqual(self.viewModel.indicatorRadius, self.expectedRadii.indicatorRadius, "Wrong indicatorRadius") + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntent should be called once") + let colorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentReceivedArguments, "Couldn't unwrap colorsReceivedArguments") + XCTAssertEqual(colorsReceivedArguments.intent, self.intent, "Wrong colorsReceivedArguments.intent") + XCTAssertIdentical(colorsReceivedArguments.theme as? ThemeGeneratedMock, self.theme, "Wrong colorsReceivedArguments.theme") + XCTAssertEqual(self.viewModel.trackColor.uiColor, self.expectedColors.track.uiColor, "Wrong trackColor") + XCTAssertEqual(self.viewModel.indicatorColor.uiColor, self.expectedColors.indicator.uiColor, "Wrong indicatorColor") + XCTAssertEqual(self.viewModel.handleColor.uiColor, self.expectedColors.handle.uiColor, "Wrong handleColor") + XCTAssertEqual(self.viewModel.handleActiveIndicatorColor.uiColor, self.expectedColors.handleActiveIndicator.uiColor, "Wrong handleActiveIndicatorColor") + + // THEN - ClosestValues + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") + XCTAssertEqual(self.publishers.handleColor.sinkCount, 1, "$handleColor should have been called once") + XCTAssertEqual(self.publishers.handleActiveIndicatorColor.sinkCount, 1, "$handleActiveIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.trackColor.sinkCount, 1, "$trackColor should have been called once") + XCTAssertEqual(self.publishers.indicatorColor.sinkCount, 1, "$indicatorColor should have been called once") + XCTAssertEqual(self.publishers.trackRadius.sinkCount, 1, "$trackRadius should have been called once") + XCTAssertEqual(self.publishers.indicatorRadius.sinkCount, 1, "$indicatorRadius should have been called once") + } + + // MARK: - Theme + func test_theme_didSet() throws { + // GIVEN - Inits from setUp() + let newTheme = ThemeGeneratedMock() + newTheme.colors = ColorsGeneratedMock.mocked() + newTheme.dims = DimsGeneratedMock.mocked() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.theme = newTheme + + // THEN - Var + XCTAssertIdentical(self.viewModel.theme as? ThemeGeneratedMock, newTheme, "Wrong theme") + + // THEN - Corner Radii + XCTAssertEqual(self.getCornerRadiiUseCase.executeWithThemeAndShapeCallsCount, 1, "getCornerRadiiUseCase.executeWithThemeAndShape should be called once") + let radiiReceivedArguments = try XCTUnwrap(self.getCornerRadiiUseCase.executeWithThemeAndShapeReceivedArguments, "Couldn't unwrap radiiReceivedArguments") + XCTAssertIdentical(radiiReceivedArguments.theme as? ThemeGeneratedMock, newTheme, "Wrong radiiReceivedArguments.theme") + + // THEN - Colors + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntent should be called once") + let colorsReceivedArguments = try XCTUnwrap(self.getColorsUseCase.executeWithThemeAndIntentReceivedArguments, "Couldn't unwrap colorsReceivedArguments") + XCTAssertIdentical(colorsReceivedArguments.theme as? ThemeGeneratedMock, newTheme, "Wrong colorsReceivedArguments.theme") + + // THEN - ClosestValues + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should have been called once") + XCTAssertEqual(self.publishers.handleColor.sinkCount, 1, "$handleColor should have been called once") + XCTAssertEqual(self.publishers.handleActiveIndicatorColor.sinkCount, 1, "$handleActiveIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.trackColor.sinkCount, 1, "$trackColor should have been called once") + XCTAssertEqual(self.publishers.indicatorColor.sinkCount, 1, "$indicatorColor should have been called once") + XCTAssertEqual(self.publishers.trackRadius.sinkCount, 1, "$trackRadius should have been called once") + XCTAssertEqual(self.publishers.indicatorRadius.sinkCount, 1, "$indicatorRadius should have been called once") + } + + // MARK: - Is Enabled + func test_isEnabled_didSet_not_equal() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isEnabled = false + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertEqual(self.publishers.dim.sinkCount, 1, "$dim should be called once") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + func test_isEnabled_didSet_equal() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.isEnabled = true + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + // MARK: - Intent + func test_intent_didSet_not_equal() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.intent = .neutral + + // THEN - UseCases + XCTAssertEqual(self.getColorsUseCase.executeWithThemeAndIntentCallsCount, 1, "getColorsUseCase.executeWithThemeAndIntent should have been called once") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertEqual(self.publishers.handleColor.sinkCount, 1, "$handleColor should have been called once") + XCTAssertEqual(self.publishers.handleActiveIndicatorColor.sinkCount, 1, "$handleActiveIndicatorColor should have been called once") + XCTAssertEqual(self.publishers.trackColor.sinkCount, 1, "$trackColor should have been called once") + XCTAssertEqual(self.publishers.indicatorColor.sinkCount, 1, "$indicatorColor should have been called once") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + func test_intent_didSet_equal() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.intent = self.intent + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + // MARK: - Shape + func test_shape_didSet_not_equal() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.shape = .square + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertEqual(self.getCornerRadiiUseCase.executeWithThemeAndShapeCallsCount, 1, "getCornerRadiiUseCase.executeWithThemeAndShape should have been called once") + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertEqual(self.publishers.trackRadius.sinkCount, 1, "$trackRadius should have been called once") + XCTAssertEqual(self.publishers.indicatorRadius.sinkCount, 1, "$indicatorRadius should have been called once") + } + + func test_shape_didSet_equal() { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + + // WHEN + self.viewModel.shape = self.shape + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertFalse(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCalled, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn't have been called") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + // MARK: - Get Closest Value + func test_getClosestValue_default_variables() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReturnValue = 0.31 + + // WHEN + let returnedValue = self.viewModel.getClosestValue(fromValue: 0.4) + + // THEN + XCTAssertEqual(returnedValue, 0.31, "Wrong returnedValue") + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape should not have been called") + XCTAssertEqual(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCallsCount, 1, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue should be called once") + let receivedArguments = try XCTUnwrap(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReceivedArguments, "Couldn't unwrap receivedArguments") + XCTAssertEqual(receivedArguments.from, 0, "Wrong received from") + XCTAssertEqual(receivedArguments.to, 1, "Wrong received to") + XCTAssertEqual(receivedArguments.steps, 0.0, "Wrong received steps") + XCTAssertEqual(receivedArguments.value, 0.4, "Wrong received value") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + func test_getClosestValue_updated_variables() throws { + // GIVEN - Inits from setUp() + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReturnValue = 7.6 + self.viewModel.steps = 0.5 + self.viewModel.minimumValue = 1 + self.viewModel.maximumValue = 10 + + // WHEN + let returnedValue = self.viewModel.getClosestValue(fromValue: 4.23) + + // THEN + XCTAssertEqual(returnedValue, 7.6, "Wrong returnedValue") + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape should not have been called") + XCTAssertEqual(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCallsCount, 1, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue should be called once") + let receivedArguments = try XCTUnwrap(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReceivedArguments, "Couldn't unwrap receivedArguments") + XCTAssertEqual(receivedArguments.from, 1, "Wrong received from") + XCTAssertEqual(receivedArguments.to, 10, "Wrong received to") + XCTAssertEqual(receivedArguments.steps, 0.5, "Wrong received steps") + XCTAssertEqual(receivedArguments.value, 4.23, "Wrong received value") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + } + + func resetUseCases() { + self.getColorsUseCase.reset() + self.getCornerRadiiUseCase.reset() + self.getClosestValueUseCase.reset() + } +} + +class SliderPublishers { + var cancellables = Set() + var dim: PublisherMock.Publisher> + var trackColor: PublisherMock.Publisher> + var handleColor: PublisherMock.Publisher> + var indicatorColor: PublisherMock.Publisher> + var handleActiveIndicatorColor: PublisherMock.Publisher> + var trackRadius: PublisherMock.Publisher> + var indicatorRadius: PublisherMock.Publisher> + + init(dim: PublisherMock.Publisher>, + trackColor: PublisherMock.Publisher>, + handleColor: PublisherMock.Publisher>, + indicatorColor: PublisherMock.Publisher>, + handleActiveIndicatorColor: PublisherMock.Publisher>, + trackRadius: PublisherMock.Publisher>, + indicatorRadius: PublisherMock.Publisher>) { + self.dim = dim + self.trackColor = trackColor + self.handleColor = handleColor + self.indicatorColor = indicatorColor + self.handleActiveIndicatorColor = handleActiveIndicatorColor + self.trackRadius = trackRadius + self.indicatorRadius = indicatorRadius + } + + func load() { + self.cancellables = Set() + [self.dim, self.trackRadius, self.indicatorRadius].forEach { + $0.loadTesting(on: &self.cancellables) + } + [self.trackColor, self.handleColor, self.indicatorColor, self.handleActiveIndicatorColor].forEach { + $0.loadTesting(on: &self.cancellables) + } + } + + func reset() { + [self.dim, self.trackRadius, self.indicatorRadius].forEach { + $0.reset() + } + [self.trackColor, self.handleColor, self.indicatorColor, self.handleActiveIndicatorColor].forEach { + $0.reset() + } + } +} diff --git a/core/Sources/Components/Slider/ViewModel/Single/SingleSiderViewModel.swift b/core/Sources/Components/Slider/ViewModel/Single/SingleSiderViewModel.swift new file mode 100644 index 000000000..312962279 --- /dev/null +++ b/core/Sources/Components/Slider/ViewModel/Single/SingleSiderViewModel.swift @@ -0,0 +1,21 @@ +// +// SingleSiderViewModel.swift +// SparkCore +// +// Created by louis.borlee on 11/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +final class SingleSliderViewModel: SliderViewModel { + + // MARK: - Published values + @Published private(set) var value: Float = .zero + + func setAbsoluteValue(_ value: Float) { + let boundedValue = max(self.minimumValue, min(self.maximumValue, value)) + let newValue = super.getClosestValue(fromValue: boundedValue) + self.value = newValue + } +} diff --git a/core/Sources/Components/Slider/ViewModel/Single/SingleSliderViewModelTests.swift b/core/Sources/Components/Slider/ViewModel/Single/SingleSliderViewModelTests.swift new file mode 100644 index 000000000..61ddfa291 --- /dev/null +++ b/core/Sources/Components/Slider/ViewModel/Single/SingleSliderViewModelTests.swift @@ -0,0 +1,172 @@ +// +// SingleSliderViewModelTests.swift +// SparkCoreUnitTests +// +// Created by louis.borlee on 11/12/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class SingleSliderViewModelTests: SliderViewModelTests { + + var singlePublishers: SingleSliderPublishers { + return self.publishers as! SingleSliderPublishers + } + + override func setupPublishers() { + self.publishers = SingleSliderPublishers( + dim: PublisherMock(publisher: self.viewModel.$dim), + trackColor: PublisherMock(publisher: self.viewModel.$trackColor), + handleColor: PublisherMock(publisher: self.viewModel.$handleColor), + indicatorColor: PublisherMock(publisher: self.viewModel.$indicatorColor), + handleActiveIndicatorColor: PublisherMock(publisher: self.viewModel.$handleActiveIndicatorColor), + trackRadius: PublisherMock(publisher: self.viewModel.$trackRadius), + indicatorRadius: PublisherMock(publisher: self.viewModel.$indicatorRadius), + value: PublisherMock(publisher: self.viewModel.$value) + ) + self.publishers.load() + } + + func test_setAbsoluteValue_outOfBounds() throws { + // GIVEN + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReturnValue = 1 + self.viewModel.minimumValue = 0 + self.viewModel.maximumValue = 4 + self.viewModel.steps = 1 + + // WHEN + self.viewModel.setAbsoluteValue(2) + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertEqual(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCallsCount, 1, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn have been called once") + let receivedArguments = try XCTUnwrap(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReceivedArguments, "Couldn't unwrap receivedArguments") + XCTAssertEqual(receivedArguments.value, 2, "Wrong receivedArguments.value") + XCTAssertEqual(receivedArguments.from, 0, "Wrong receivedArguments.from") + XCTAssertEqual(receivedArguments.to, 4, "Wrong receivedArguments.to") + XCTAssertEqual(receivedArguments.steps, 1, "Wrong receivedArguments.steps") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + XCTAssertEqual(self.singlePublishers.value.sinkCount, 1, "$value should have been called once") + XCTAssertEqual(self.singlePublishers.value.sinkValue, 1, "Wrong value.sinkValue") + } + + func test_setAbsoluteValue_outOfBounds_under() throws { + // GIVEN + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReturnValue = 3 + self.viewModel.minimumValue = 0 + self.viewModel.maximumValue = 4 + self.viewModel.steps = 1 + + // WHEN + self.viewModel.setAbsoluteValue(-1) + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertEqual(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCallsCount, 1, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn have been called once") + let receivedArguments = try XCTUnwrap(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReceivedArguments, "Couldn't unwrap receivedArguments") + XCTAssertEqual(receivedArguments.value, 0, "Wrong receivedArguments.value") + XCTAssertEqual(receivedArguments.from, 0, "Wrong receivedArguments.from") + XCTAssertEqual(receivedArguments.to, 4, "Wrong receivedArguments.to") + XCTAssertEqual(receivedArguments.steps, 1, "Wrong receivedArguments.steps") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + XCTAssertEqual(self.singlePublishers.value.sinkCount, 1, "$value should have been called once") + XCTAssertEqual(self.singlePublishers.value.sinkValue, 3, "Wrong value.sinkValue") + } + + func test_setAbsoluteValue_outOfBounds_over() throws { + // GIVEN + self.resetUseCases() // Removes execute from init + self.publishers.reset() // Removes publishes from init + self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReturnValue = 2 + self.viewModel.minimumValue = 0 + self.viewModel.maximumValue = 4 + self.viewModel.steps = 1 + + // WHEN + self.viewModel.setAbsoluteValue(5) + + // THEN - UseCases + XCTAssertFalse(self.getColorsUseCase.executeWithThemeAndIntentCalled, "getColorsUseCase.executeWithThemeAndIntent shouldn't have been called") + XCTAssertFalse(self.getCornerRadiiUseCase.executeWithThemeAndShapeCalled, "getCornerRadiiUseCase.executeWithThemeAndShape shouldn't have been called") + XCTAssertEqual(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueCallsCount, 1, "getClosestValueUseCase.executeWithFromAndToAndStepsAndValue shouldn have been called once") + let receivedArguments = try XCTUnwrap(self.getClosestValueUseCase.executeWithFromAndToAndStepsAndValueReceivedArguments, "Couldn't unwrap receivedArguments") + XCTAssertEqual(receivedArguments.value, 4, "Wrong receivedArguments.value") + XCTAssertEqual(receivedArguments.from, 0, "Wrong receivedArguments.from") + XCTAssertEqual(receivedArguments.to, 4, "Wrong receivedArguments.to") + XCTAssertEqual(receivedArguments.steps, 1, "Wrong receivedArguments.steps") + + // THEN - Publishers + XCTAssertFalse(self.publishers.dim.sinkCalled, "$dim should not have been called") + XCTAssertFalse(self.publishers.handleColor.sinkCalled, "$handleColor should not have been called") + XCTAssertFalse(self.publishers.handleActiveIndicatorColor.sinkCalled, "$handleActiveIndicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackColor.sinkCalled, "$trackColor should not have been called") + XCTAssertFalse(self.publishers.indicatorColor.sinkCalled, "$indicatorColor should not have been called") + XCTAssertFalse(self.publishers.trackRadius.sinkCalled, "$trackRadius should not have been called") + XCTAssertFalse(self.publishers.indicatorRadius.sinkCalled, "$indicatorRadius should not have been called") + XCTAssertEqual(self.singlePublishers.value.sinkCount, 1, "$value should have been called once") + XCTAssertEqual(self.singlePublishers.value.sinkValue, 2, "Wrong value.sinkValue") + } + + func test_init_value() throws { + // GIVEN / WHEN - Inits from setUp() + + // THEN + XCTAssertEqual(self.singlePublishers.value.sinkCount, 1, "$value should have been called once") + } +} + +final class SingleSliderPublishers: SliderPublishers { + var value: PublisherMock.Publisher> + + init(dim: PublisherMock.Publisher>, + trackColor: PublisherMock.Publisher>, + handleColor: PublisherMock.Publisher>, + indicatorColor: PublisherMock.Publisher>, + handleActiveIndicatorColor: PublisherMock.Publisher>, + trackRadius: PublisherMock.Publisher>, + indicatorRadius: PublisherMock.Publisher>, + value: PublisherMock.Publisher>) { + self.value = value + super.init(dim: dim, + trackColor: trackColor, + handleColor: handleColor, + indicatorColor: indicatorColor, + handleActiveIndicatorColor: handleActiveIndicatorColor, + trackRadius: trackRadius, + indicatorRadius: indicatorRadius) + } + + override func load() { + super.load() + self.value.loadTesting(on: &self.cancellables) + } + + override func reset() { + super.reset() + self.value.reset() + } +} diff --git a/core/Sources/Components/Slider/ViewModel/SliderViewModel.swift b/core/Sources/Components/Slider/ViewModel/SliderViewModel.swift deleted file mode 100644 index 39cd6b825..000000000 --- a/core/Sources/Components/Slider/ViewModel/SliderViewModel.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// SliderViewModel.swift -// SparkCore -// -// Created by louis.borlee on 23/11/2023. -// Copyright © 2023 Adevinta. All rights reserved. -// - -import Foundation - -class SliderViewModel { - - let getColorsUseCase: SliderGetColorsUseCasable - let getCornerRadiiUseCase: SliderGetCornerRadiiUseCase - let getClosestValueUseCase: SliderGetClosestValueUseCasable - - init(getColorsUseCase: SliderGetColorsUseCasable = SliderGetColorsUseCase(), - getCornerRadiiUseCase: SliderGetCornerRadiiUseCase = SliderGetCornerRadiiUseCase(), - getClosestValueUseCase: SliderGetClosestValueUseCasable = SliderGetClosestValueUseCase()) { - self.getColorsUseCase = getColorsUseCase - self.getCornerRadiiUseCase = getCornerRadiiUseCase - self.getClosestValueUseCase = getClosestValueUseCase - } -} - -final class SingleSliderViewModel: SliderViewModel { - -} - -final class RangeSliderViewModel: SliderViewModel { - -} diff --git a/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIView.swift b/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIView.swift index 2d56bcccb..ae4a34fd2 100644 --- a/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIView.swift +++ b/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIView.swift @@ -34,11 +34,10 @@ final class ChipComponentUIView: ComponentUIView { } private static func makeChipView(viewModel: ChipComponentUIViewModel) -> ChipUIView { - // swiftlint:disable all let chipView = ChipUIView(theme: viewModel.theme, - intent: viewModel.intent, - variant: viewModel.variant, - label: viewModel.title ?? "No Title") + intent: viewModel.intent, + variant: viewModel.variant, + label: viewModel.title ?? "No Title") return chipView } diff --git a/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIViewModel.swift index 5c8999dbd..48c872590 100644 --- a/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIViewModel.swift +++ b/spark/Demo/Classes/View/Components/Chip/UIKit/ChipComponentUIViewModel.swift @@ -144,7 +144,6 @@ final class ChipComponentUIViewModel: ComponentUIViewModel { } } - // swiftlint:disable all var hasAction: Bool { set { self.action = newValue ? {} : nil @@ -253,7 +252,6 @@ final class ChipComponentUIViewModel: ComponentUIViewModel { extension ChipComponentUIViewModel { @objc func deleteItem() { - } @objc func presentThemeSheet() { diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index 04df183b5..4f0f4f93b 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -84,6 +84,8 @@ extension ComponentsViewController { viewController = IconComponentUIViewController.build() case .radioButton: viewController = RadioButtonComponentUIViewController.build() + case .slider: + viewController = SliderComponentUIViewController.build() case .spinner: viewController = SpinnerComponentUIViewController.build() case .switchButton: @@ -112,6 +114,7 @@ private extension ComponentsViewController { case chip case icon case radioButton + case slider case spinner case switchButton case tab diff --git a/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIControl.swift b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIControl.swift new file mode 100644 index 000000000..12ad39a95 --- /dev/null +++ b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIControl.swift @@ -0,0 +1,390 @@ +// +// SliderComponentUIControl.swift +// SparkDemo +// +// Created by louis.borlee on 24/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Combine +import SparkCore +import Spark +import UIKit + +// swiftlint:disable no_debugging_method +final class SliderComponentUIView: UIView { + + private let numberFormatter = NumberFormatter() + + private lazy var configurationLabel: UILabel = { + let label = UILabel() + label.text = "Configuration" + label.font = UIFont.systemFont(ofSize: 22, weight: .bold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private lazy var themeLabel: UILabel = { + let label = UILabel() + label.text = "Theme:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var themeButton: UIButton = { + let button = UIButton() + button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) + button.addTarget(self.viewModel, action: #selector(viewModel.presentThemeSheet), for: .touchUpInside) + button.titleLabel?.font = UIFont.systemFont(ofSize: 17) + return button + }() + + private lazy var themeStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [themeLabel, themeButton, UIView()]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var intentLabel: UILabel = { + let label = UILabel() + label.text = "Intent:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var intentButton: UIButton = { + let button = UIButton() + button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) + button.addTarget(self.viewModel, action: #selector(self.viewModel.presentIntentSheet), for: .touchUpInside) + button.titleLabel?.font = UIFont.systemFont(ofSize: 17) + return button + }() + + private lazy var intentStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.intentLabel, self.intentButton, UIView()]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var shapeLabel: UILabel = { + let label = UILabel() + label.text = "Shape:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var shapeButton: UIButton = { + let button = UIButton() + button.setTitleColor(self.viewModel.theme.colors.main.main.uiColor, for: .normal) + button.addTarget(self.viewModel, action: #selector(self.viewModel.presentSizeSheet), for: .touchUpInside) + button.titleLabel?.font = UIFont.systemFont(ofSize: 17) + return button + }() + + private lazy var shapeStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.shapeLabel, self.shapeButton, UIView()]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var isContinuousCheckbox: CheckboxUIView = { + return CheckboxUIView( + theme: self.viewModel.theme, + text: "isContinuous", + checkedImage: DemoIconography.shared.checkmark, + selectionState: .selected, + alignment: .left + ) + }() + + private lazy var isEnabledCheckbox: CheckboxUIView = { + return CheckboxUIView( + theme: self.viewModel.theme, + text: "isEnabled", + checkedImage: DemoIconography.shared.checkmark, + selectionState: .selected, + alignment: .left + ) + }() + + private lazy var valueLabel: UILabel = { + let label = UILabel() + label.text = "Value:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var valueTextField: UITextField = { + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) + textField.text = "0" + textField.borderStyle = .roundedRect + textField.addDoneButtonOnKeyboard() + textField.delegate = self + return textField + }() + + private lazy var valueStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.valueLabel, self.valueTextField]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var stepsLabel: UILabel = { + let label = UILabel() + label.text = "Steps:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var stepsTextField: UITextField = { + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) + textField.text = "0.0" + textField.borderStyle = .roundedRect + textField.addDoneButtonOnKeyboard() + textField.delegate = self + return textField + }() + + private lazy var stepsStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.stepsLabel, self.stepsTextField]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var minimumValueLabel: UILabel = { + let label = UILabel() + label.text = "Minimum value:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var minimumValueTextField: UITextField = { + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) + textField.text = "0.0" + textField.borderStyle = .roundedRect + textField.addDoneButtonOnKeyboard() + textField.delegate = self + return textField + }() + + private lazy var minimumValueStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.minimumValueLabel, self.minimumValueTextField]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var maximumValueLabel: UILabel = { + let label = UILabel() + label.text = "Maximum value:" + label.font = UIFont.systemFont(ofSize: 17, weight: .bold) + return label + }() + + private lazy var maximumValueTextField: UITextField = { + let textField = UITextField(frame: CGRect(x: 0, y: 0, width: 0, height: 44)) + textField.text = "1.0" + textField.borderStyle = .roundedRect + textField.addDoneButtonOnKeyboard() + textField.delegate = self + return textField + }() + + private lazy var maximumValueStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.maximumValueLabel, self.maximumValueTextField]) + stackView.axis = .horizontal + stackView.spacing = 10 + return stackView + }() + + private lazy var configurationStackView: UIStackView = { + let stackView = UIStackView( + arrangedSubviews: [ + self.themeStackView, + self.shapeStackView, + self.intentStackView, + self.isContinuousCheckbox, + self.isEnabledCheckbox, + self.stepsStackView, + self.valueStackView, + self.minimumValueStackView, + self.maximumValueStackView + ] + ) + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + private lazy var lineView: UIView = { + let view = UIView() + view.backgroundColor = .lightGray + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private lazy var integrationLabel: UILabel = { + let label = UILabel() + label.text = "Integration" + label.font = UIFont.systemFont(ofSize: 22, weight: .bold) + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + lazy var slider: SliderUIControl = { + let slider = SliderUIControl( + theme: self.viewModel.theme, + shape: self.viewModel.shape, + intent: self.viewModel.intent + ) + slider.minimumValue = 0 + slider.maximumValue = 1 + slider.translatesAutoresizingMaskIntoConstraints = false + return slider + }() + + // MARK: - Properties + private let viewModel: SliderComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Initializer + init(viewModel: SliderComponentUIViewModel) { + self.viewModel = viewModel + super.init(frame: .zero) + + self.setupView() + self.addPublishers() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup Views + private func setupView() { + self.backgroundColor = UIColor.systemBackground + + addSubview(self.configurationLabel) + addSubview(self.configurationStackView) + addSubview(self.lineView) + addSubview(self.integrationLabel) + addSubview(self.slider) + + NSLayoutConstraint.activate([ + self.configurationLabel.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + self.configurationLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + + self.configurationStackView.topAnchor.constraint(equalTo: self.configurationLabel.bottomAnchor, constant: 16), + self.configurationStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), + self.configurationStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16), + + self.lineView.topAnchor.constraint(equalTo: self.configurationStackView.bottomAnchor, constant: 16), + self.lineView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), + self.lineView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -16), + self.lineView.heightAnchor.constraint(equalToConstant: 1), + + self.integrationLabel.topAnchor.constraint(equalTo: self.lineView.bottomAnchor, constant: 16), + self.integrationLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), + + self.slider.topAnchor.constraint(equalTo: self.integrationLabel.bottomAnchor, constant: 16), + self.slider.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 16), + self.slider.centerXAnchor.constraint(equalTo: self.centerXAnchor), + ]) + } + + // MARK: - Publishers + private func addPublishers() { + self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in + guard let self = self else { return } + let color = self.viewModel.theme.colors.main.main.uiColor + let themeTitle: String? = theme is SparkTheme ? self.viewModel.themes.first?.title : self.viewModel.themes.last?.title + self.themeButton.setTitle(themeTitle, for: .normal) + self.themeButton.setTitleColor(color, for: .normal) + self.intentButton.setTitleColor(color, for: .normal) + self.shapeButton.setTitleColor(color, for: .normal) + self.slider.theme = theme + } + + self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in + guard let self = self else { return } + self.intentButton.setTitle(intent.name, for: .normal) + self.slider.intent = intent + } + + self.viewModel.$shape.subscribe(in: &self.cancellables) { [weak self] shape in + guard let self = self else { return } + self.shapeButton.setTitle(shape.name, for: .normal) + self.slider.shape = shape + } + + self.isContinuousCheckbox.publisher.subscribe(in: &self.cancellables) { [weak self] state in + guard let self = self else { return } + self.slider.isContinuous = state == .unselected ? false : true + } + + self.isEnabledCheckbox.publisher.subscribe(in: &self.cancellables) { [weak self] state in + guard let self = self else { return } + self.slider.isEnabled = state == .unselected ? false : true + } + + self.slider.valuePublisher.subscribe(in: &self.cancellables) { [weak self] value in + guard let self else { return } + self.valueTextField.text = "\(value)" + } + + self.slider.addAction(UIAction(handler: { [weak self] _ in + guard let self else { return } + print("Slider.valuedChanged: \(self.slider.value)") + }), for: .valueChanged) + } +} + +extension SliderComponentUIView: UITextFieldDelegate { + private func setValue(_ value: Float) { + self.slider.setValue(value) + self.valueTextField.text = "\(self.slider.value)" + } + + private func setSteps(_ steps: Float) { + self.slider.steps = steps + self.stepsTextField.text = "\(self.slider.steps)" + } + + private func setMinimumValue(_ minimumValue: Float) { + self.slider.minimumValue = minimumValue + self.minimumValueTextField.text = "\(self.slider.minimumValue)" + } + + private func setMaximumValue(_ maximumValue: Float) { + self.slider.maximumValue = maximumValue + self.maximumValueTextField.text = "\(self.slider.maximumValue)" + } + + func textFieldDidEndEditing(_ textField: UITextField, reason: UITextField.DidEndEditingReason) { + guard let text = textField.text, + let number = self.numberFormatter.number(from: text) else { return } + let float = Float(truncating: number) + switch textField { + case self.valueTextField: + self.setValue(float) + case self.stepsTextField: + self.setSteps(float) + case self.minimumValueTextField: + self.setMinimumValue(float) + case self.maximumValueTextField: + self.setMaximumValue(float) + default: + break + } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } +} diff --git a/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift new file mode 100644 index 000000000..11a67d672 --- /dev/null +++ b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewController.swift @@ -0,0 +1,118 @@ +// +// SliderComponentUIViewController.swift +// SparkDemo +// +// Created by louis.borlee on 24/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Combine +import Spark +import SparkCore +import SwiftUI +import UIKit + +// swiftlint:disable no_debugging_method +final class SliderComponentUIViewController: UIViewController { + + // MARK: - Published Properties + @ObservedObject private var themePublisher = SparkThemePublisher.shared + + // MARK: - Properties + let componentView: SliderComponentUIView + let viewModel: SliderComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Initializer + init(viewModel: SliderComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = SliderComponentUIView(viewModel: viewModel) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func loadView() { + view = self.componentView + } + + // MARK: - ViewDidLoad + override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .systemBackground + self.navigationItem.title = "Slider" + self.addPublisher() + + self.componentView.slider.addAction(UIAction(handler: { [weak self] _ in + guard let self else { return } + }), for: .valueChanged) + } + + // MARK: - Add Publishers + private func addPublisher() { + self.themePublisher + .$theme + .sink { [weak self] theme in + guard let self = self else { return } + self.viewModel.theme = theme + self.navigationController?.navigationBar.tintColor = theme.colors.main.main.uiColor + } + .store(in: &self.cancellables) + + self.viewModel.showThemeSheet.subscribe(in: &self.cancellables) { intents in + self.presentThemeActionSheet(intents) + } + + self.viewModel.showIntentSheet.subscribe(in: &self.cancellables) { intents in + self.presentIntentActionSheet(intents) + } + + self.viewModel.showSizeSheet.subscribe(in: &self.cancellables) { intents in + self.presentShapeActionSheet(intents) + } + } +} + +// MARK: - Builder +extension SliderComponentUIViewController { + + static func build() -> SliderComponentUIViewController { + let viewModel = SliderComponentUIViewModel(theme: SparkThemePublisher.shared.theme) + let viewController = SliderComponentUIViewController(viewModel: viewModel) + return viewController + } +} + +// MARK: - Navigation +extension SliderComponentUIViewController { + + private func presentThemeActionSheet(_ themes: [ThemeCellModel]) { + let actionSheet = SparkActionSheet.init( + values: themes.map { $0.theme }, + texts: themes.map { $0.title }) { theme in + self.themePublisher.theme = theme + } + self.present(actionSheet, animated: true) + } + + private func presentIntentActionSheet(_ intents: [SliderIntent]) { + let actionSheet = SparkActionSheet.init( + values: intents, + texts: intents.map { $0.name }) { intent in + self.viewModel.intent = intent + } + self.present(actionSheet, animated: true) + } + + private func presentShapeActionSheet(_ sizes: [SliderShape]) { + let actionSheet = SparkActionSheet.init( + values: sizes, + texts: sizes.map { $0.name }) { shape in + self.viewModel.shape = shape + } + self.present(actionSheet, animated: true) + } +} diff --git a/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewModel.swift new file mode 100644 index 000000000..4e2195152 --- /dev/null +++ b/spark/Demo/Classes/View/Components/Slider/UIKit/SliderComponentUIViewModel.swift @@ -0,0 +1,69 @@ +// +// SliderComponentUIViewModel.swift +// SparkDemo +// +// Created by louis.borlee on 24/11/2023. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Combine +import Spark +import SparkCore +import UIKit + +final class SliderComponentUIViewModel: ObservableObject { + + // MARK: - Published Properties + var showThemeSheet: AnyPublisher<[ThemeCellModel], Never> { + self.showThemeSheetSubject + .eraseToAnyPublisher() + } + + var showIntentSheet: AnyPublisher<[SliderIntent], Never> { + self.showIntentSheetSubject + .eraseToAnyPublisher() + } + + var showSizeSheet: AnyPublisher<[SliderShape], Never> { + self.showShapeSheetSubject + .eraseToAnyPublisher() + } + + let themes = ThemeCellModel.themes + + // MARK: - Private Properties + private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() + private var showIntentSheetSubject: PassthroughSubject<[SliderIntent], Never> = .init() + private var showShapeSheetSubject: PassthroughSubject<[SliderShape], Never> = .init() + + // MARK: - Initialization + @Published var theme: Theme + @Published var intent: SliderIntent + @Published var shape: SliderShape + + init( + theme: Theme, + intent: SliderIntent = .main, + shape: SliderShape = .rounded + ) { + self.theme = theme + self.intent = intent + self.shape = shape + } +} + +// MARK: - Navigation +extension SliderComponentUIViewModel { + + @objc func presentThemeSheet() { + self.showThemeSheetSubject.send(self.themes) + } + + @objc func presentIntentSheet() { + self.showIntentSheetSubject.send(SliderIntent.allCases) + } + + @objc func presentSizeSheet() { + self.showShapeSheetSubject.send(SliderShape.allCases) + } +} diff --git a/spark/Demo/Classes/View/Components/Tab/SwiftUI/TabComponent.swift b/spark/Demo/Classes/View/Components/Tab/SwiftUI/TabComponent.swift index dd3c24d01..28240f009 100644 --- a/spark/Demo/Classes/View/Components/Tab/SwiftUI/TabComponent.swift +++ b/spark/Demo/Classes/View/Components/Tab/SwiftUI/TabComponent.swift @@ -210,7 +210,6 @@ private extension Image { "magazine" ] - // swiftlint: disable force_unwrapping static func image(at index: Int) -> Image { let allSfs: [String] = names.flatMap{ [$0, "\($0).fill"] } let imageName = allSfs[index % names.count]