diff --git a/core/Sources/Common/Foundation/Extension/CGPoint-Distance.swift b/core/Sources/Common/Foundation/Extension/CGPoint-Distance.swift new file mode 100644 index 000000000..afcd7053e --- /dev/null +++ b/core/Sources/Common/Foundation/Extension/CGPoint-Distance.swift @@ -0,0 +1,17 @@ +// +// CGPoint-Distance.swift +// SparkCore +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +extension CGPoint { + + /// Returns the distance between two points + func distance(to other: CGPoint) -> CGFloat { + CGFloat(hypotf(Float(self.x - other.x), Float(self.y - other.y))) + } +} diff --git a/core/Sources/Common/Foundation/Extension/CGPointDistanceTests.swift b/core/Sources/Common/Foundation/Extension/CGPointDistanceTests.swift new file mode 100644 index 000000000..a3c9ba589 --- /dev/null +++ b/core/Sources/Common/Foundation/Extension/CGPointDistanceTests.swift @@ -0,0 +1,25 @@ +// +// CGPointDistanceTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest + +@testable import SparkCore + +final class CGPointDistanceTests: XCTestCase { + + func test_distance_same() throws { + let point1 = CGPoint(x: 10, y: 10) + let point2 = CGPoint(x: 10, y: -100) + + let distance1 = point1.distance(to: point2) + let distance2 = point2.distance(to: point1) + + XCTAssertEqual(distance1, 110.0, "Expected distance does not match") + XCTAssertEqual(distance1, distance2, "Expected both distances to be the same") + } +} diff --git a/core/Sources/Common/Foundation/Extension/CGRect-Center.swift b/core/Sources/Common/Foundation/Extension/CGRect-Center.swift new file mode 100644 index 000000000..2cd9410bd --- /dev/null +++ b/core/Sources/Common/Foundation/Extension/CGRect-Center.swift @@ -0,0 +1,26 @@ +// +// CGRect.swift +// SparkCore +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +extension CGRect { + /// Returns the center of the x-coordinate of the rect + var centerX: CGFloat { + return (self.minX + self.maxX)/2 + } + + /// Returns the center of the y-coordinate of the rect + var centerY: CGFloat { + return (self.minY + self.maxY)/2 + } + + /// The center point of the rect + var center: CGPoint { + return CGPoint(x: self.centerX, y: self.centerY) + } +} diff --git a/core/Sources/Common/Foundation/Extension/CGRectCenterTests.swift b/core/Sources/Common/Foundation/Extension/CGRectCenterTests.swift new file mode 100644 index 000000000..207c0ed59 --- /dev/null +++ b/core/Sources/Common/Foundation/Extension/CGRectCenterTests.swift @@ -0,0 +1,24 @@ +// +// CGRectCenterTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest + +@testable import SparkCore + +final class CGRectCenterTests: XCTestCase { + + func testExample() throws { + let rect = CGRect(x: 10, y: 10, width: 110, height: 30) + + XCTAssertEqual(rect.centerX, 65, "CenterX doesn't match expected value") + XCTAssertEqual(rect.centerY, 25, "CenterY doesn't match expected value") + + XCTAssertEqual(rect.center, CGPoint(x: 65, y: 25), "Center point is not correct") + } + +} diff --git a/core/Sources/Common/Foundation/Extension/UIView-Closest.swift b/core/Sources/Common/Foundation/Extension/UIView-Closest.swift new file mode 100644 index 000000000..61bcd9c05 --- /dev/null +++ b/core/Sources/Common/Foundation/Extension/UIView-Closest.swift @@ -0,0 +1,24 @@ +// +// UIView-Closest.swift +// SparkCore +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +import UIKit + +extension Array where Element: UIView { + + /// Returns the index of the array of views which is closest to the point. + func index(closestTo location: CGPoint) -> Int? { + let distances = self.map{ view in + view.frame.center.distance(to: location) + } + let nearest = distances.enumerated().min { (left, right) in + return left.element < right.element + } + return nearest?.offset + } +} diff --git a/core/Sources/Common/Foundation/Extension/UIViewClosestTests.swift b/core/Sources/Common/Foundation/Extension/UIViewClosestTests.swift new file mode 100644 index 000000000..98fd1eb6f --- /dev/null +++ b/core/Sources/Common/Foundation/Extension/UIViewClosestTests.swift @@ -0,0 +1,24 @@ +// +// UIViewClosestTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import XCTest +@testable import SparkCore + +final class UIViewClosestTests: XCTestCase { + + func test_closest() throws { + let positions = [0, 100, 200, 300] + let views = positions.map{ CGRect(x: $0, y: 10, width: 50, height: 50) }.map(UIView.init(frame:)) + + for (index, position) in positions.enumerated() { + let closestIndex = views.index(closestTo: CGPoint(x: position+50, y: 100)) + XCTAssertEqual(closestIndex, index, "Expected \(String(describing: closestIndex)) to be equal to \(index)") + } + } + +} diff --git a/core/Sources/Components/Rating/AccessibilityIdentifier/RatingInputAccessibilityIdentifier.swift b/core/Sources/Components/Rating/AccessibilityIdentifier/RatingInputAccessibilityIdentifier.swift new file mode 100644 index 000000000..26318cbb4 --- /dev/null +++ b/core/Sources/Components/Rating/AccessibilityIdentifier/RatingInputAccessibilityIdentifier.swift @@ -0,0 +1,18 @@ +// +// RatingInputAccessibilityIdentifier.swift +// SparkCore +// +// Created by Michael Zimmermann on 27.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +/// The accessibility identifiers of the rating input. +public enum RatingInputAccessibilityIdentifier { + + // MARK: - Properties + + /// The accessibility identifier. + public static let identifier = "spark-rating-input" +} diff --git a/core/Sources/Components/Rating/TestHelpers/RatingDisplayConfigurationSnapshotTests.swift b/core/Sources/Components/Rating/TestHelpers/RatingDisplayConfigurationSnapshotTests.swift index 6f716a091..5123a3a33 100644 --- a/core/Sources/Components/Rating/TestHelpers/RatingDisplayConfigurationSnapshotTests.swift +++ b/core/Sources/Components/Rating/TestHelpers/RatingDisplayConfigurationSnapshotTests.swift @@ -7,8 +7,8 @@ // import Foundation - import UIKit + @testable import SparkCore struct RatingDisplayConfigurationSnapshotTests { diff --git a/core/Sources/Components/Rating/TestHelpers/RatingDisplayScenarioSnapshotTests.swift b/core/Sources/Components/Rating/TestHelpers/RatingDisplayScenarioSnapshotTests.swift index 223c5fdda..91e4fd434 100644 --- a/core/Sources/Components/Rating/TestHelpers/RatingDisplayScenarioSnapshotTests.swift +++ b/core/Sources/Components/Rating/TestHelpers/RatingDisplayScenarioSnapshotTests.swift @@ -7,11 +7,11 @@ // import Foundation - -@testable import SparkCore import UIKit import SwiftUI +@testable import SparkCore + enum RatingDisplayScenarioSnapshotTests: String, CaseIterable { case test1 case test2 @@ -41,7 +41,7 @@ enum RatingDisplayScenarioSnapshotTests: String, CaseIterable { /// Description: To various rating values /// /// Content: - /// - ratings: [0.0, 1.0, 2.5, 5.5] + /// - ratings: [1.0, 2.5, 5.5] /// - size: medium /// - count: five (number of stars) /// - modes: all @@ -90,7 +90,7 @@ enum RatingDisplayScenarioSnapshotTests: String, CaseIterable { /// Content: /// - ratings: [2.5] /// - size: [small, medium, input] - /// - count: one (number of stars) + /// - count: five (number of stars) /// - modes: default /// - accessibility sizes: default private func test3(isSwiftUIComponent: Bool) -> [RatingDisplayConfigurationSnapshotTests] { diff --git a/core/Sources/Components/Rating/TestHelpers/RatingInputConfigurationSnapshotTests.swift b/core/Sources/Components/Rating/TestHelpers/RatingInputConfigurationSnapshotTests.swift new file mode 100644 index 000000000..6eee06813 --- /dev/null +++ b/core/Sources/Components/Rating/TestHelpers/RatingInputConfigurationSnapshotTests.swift @@ -0,0 +1,43 @@ +// +// RatingInputConfigurationSnapshotTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +import UIKit + +@testable import SparkCore + +struct RatingInputConfigurationSnapshotTests { + + // MARK: - Properties + + let scenario: RatingInputScenarioSnapshotTests + + let rating: CGFloat + let intent = RatingIntent.main + + let modes: [ComponentSnapshotTestMode] + let sizes: [UIContentSizeCategory] + let state: RatingInputState + + // MARK: - Getter + + func testName() -> String { + return [ + "\(self.scenario.rawValue)", + "\(self.intent)", + "\(self.rating)", + "\(self.state)" + ].joined(separator: "-") + } +} + +enum RatingInputState: CaseIterable { + case enabled + case disabled + case pressed +} diff --git a/core/Sources/Components/Rating/TestHelpers/RatingInputScenarioSnapshotTests.swift b/core/Sources/Components/Rating/TestHelpers/RatingInputScenarioSnapshotTests.swift new file mode 100644 index 000000000..ff36a8a56 --- /dev/null +++ b/core/Sources/Components/Rating/TestHelpers/RatingInputScenarioSnapshotTests.swift @@ -0,0 +1,105 @@ +// +// RatingInputScenarioSnapshotTests.swift +// SparkCoreUnitTests +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation +import UIKit +import SwiftUI + +@testable import SparkCore + +enum RatingInputScenarioSnapshotTests: String, CaseIterable { + + case test1 + case test2 + case test3 + + // MARK: - Type Alias + + typealias Constants = ComponentSnapshotTestConstants + + + // MARK: - Configurations + func configuration(isSwiftUIComponent: Bool) -> [RatingInputConfigurationSnapshotTests] { + switch self { + case .test1: + return self.test1(isSwiftUIComponent: isSwiftUIComponent) + case .test2: + return self.test2(isSwiftUIComponent: isSwiftUIComponent) + case .test3: + return self.test3(isSwiftUIComponent: isSwiftUIComponent) + } + } + + // MARK: - Scenarios + + /// Test 1 + /// + /// Description: To various rating values + /// + /// Content: + /// - ratings: 2.0 + /// - states: enabled + /// - modes: all + /// - accessibility sizes: default + private func test1(isSwiftUIComponent: Bool) -> [RatingInputConfigurationSnapshotTests] { + let ratings: [CGFloat] = [1.0, 5.0] + + return ratings.map { rating in + return .init( + scenario: self, + rating: rating, + modes: Constants.Modes.all, + sizes: Constants.Sizes.default, + state: .enabled + ) + } + } + + /// Test 2 + /// + /// + /// Description: To various accessibility sizes + /// + /// Content: + /// - ratings: [1.0] + /// - modes: default + /// - accessibility sizes: all + /// - states: all + private func test2(isSwiftUIComponent: Bool) -> [RatingInputConfigurationSnapshotTests] { + return [.init( + scenario: self, + rating: 1.0, + modes: Constants.Modes.default, + sizes: Constants.Sizes.all, + state: .enabled + )] + } + + /// Test 3 + /// + /// Description: To various rating values + /// + /// Content: + /// - ratings: [1.0, 5.0] + /// - states: disabled, pressed + /// - modes: all + /// - accessibility sizes: default + private func test3(isSwiftUIComponent: Bool) -> [RatingInputConfigurationSnapshotTests] { + + return [RatingInputState.disabled, .pressed].map { state in + return .init( + scenario: self, + rating: 2.0, + modes: Constants.Modes.all, + sizes: Constants.Sizes.default, + state: state + ) + } + } + +} diff --git a/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCase.swift b/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCase.swift index 3f064f820..cac431f39 100644 --- a/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCase.swift +++ b/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCase.swift @@ -42,7 +42,7 @@ struct RatingGetColorsUseCase: RatingGetColorsUseCaseable { switch intent { case .main: colors = RatingColors( - fillColor: state.isPressed ? theme.colors.main.onMain : theme.colors.main.mainVariant, + fillColor: state.isPressed ? theme.colors.states.mainVariantPressed : theme.colors.main.mainVariant, strokeColor: theme.colors.base.onSurface.opacity(theme.dims.dim3), opacity: theme.dims.none ) } diff --git a/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCaseUnitTests.swift b/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCaseUnitTests.swift index 77f51e9b7..efaac7b82 100644 --- a/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCaseUnitTests.swift +++ b/core/Sources/Components/Rating/UseCases/RatingGetColorsUseCaseUnitTests.swift @@ -67,7 +67,7 @@ final class RatingGetColorsUseCaseUnitTests: XCTestCase { // Then let expectedColors = RatingColors( - fillColor: theme.colors.main.onMain, + fillColor: theme.colors.states.mainVariantPressed, strokeColor: theme.colors.base.onSurface.opacity(theme.dims.dim3), opacity: theme.dims.none) diff --git a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift index 3e4746c95..d85da5757 100644 --- a/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift +++ b/core/Sources/Components/Rating/View/RatingDisplayViewModel.swift @@ -52,6 +52,8 @@ final class RatingDisplayViewModel: ObservableObject { } } + var ratingState: RatingState + // MARK: - Published variables /// The current defined colors @Published var colors: RatingColors @@ -64,12 +66,14 @@ final class RatingDisplayViewModel: ObservableObject { private let colorsUseCase: RatingGetColorsUseCaseable private let sizeUseCase: RatingSizeAttributesUseCaseable + // MARK: Initializer init(theme: Theme, intent: RatingIntent, size: RatingDisplaySize, count: RatingStarsCount, - rating: CGFloat, + rating: CGFloat = 0.0, + ratingState: RatingState = .standard, colorsUseCase: RatingGetColorsUseCaseable = RatingGetColorsUseCase(), sizeUseCase: RatingSizeAttributesUseCaseable = RatingSizeAttributesUseCase() ) { @@ -77,12 +81,23 @@ final class RatingDisplayViewModel: ObservableObject { self.intent = intent self.size = size self.colorsUseCase = colorsUseCase - self.colors = colorsUseCase.execute(theme: theme, intent: intent, state: .standard) + self.colors = colorsUseCase.execute(theme: theme, intent: intent, state: ratingState) self.sizeUseCase = sizeUseCase self.ratingSize = sizeUseCase.execute(spacing: theme.layout.spacing, size: size) self.ratingValue = count.ratingValue(rating) self.rating = rating self.count = count + self.ratingState = ratingState + } + + func updateState(isPressed: Bool) { + self.ratingState.isPressed = isPressed + self.colors = self.colorsUseCase.execute(theme: theme, intent: intent, state: self.ratingState) + } + + func updateState(isEnabled: Bool) { + self.ratingState.isEnabled = isEnabled + self.colors = self.colorsUseCase.execute(theme: theme, intent: intent, state: self.ratingState) } // MARK: - Private functions diff --git a/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift b/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift index 1b5197d57..74418ada8 100644 --- a/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift +++ b/core/Sources/Components/Rating/View/UIKit/RatingDisplayUIView.swift @@ -28,11 +28,6 @@ public class RatingDisplayUIView: UIView { private var sizeConstraints = [NSLayoutConstraint]() private var cancellable = Set() - private var ratingStarViews : [StarUIView] { self.stackView.arrangedSubviews.compactMap { view in - return view as? StarUIView - } - } - // MARK: - Public accessors /// Count: the number of stars to show in the rating. /// Only values five and one are allowed, five is the default. @@ -88,7 +83,31 @@ public class RatingDisplayUIView: UIView { self.viewModel.size = newValue } } - + + // MARK: - Internal accessors + internal var isPressed: Bool { + get { + self.viewModel.ratingState.isPressed + } + set { + self.viewModel.updateState(isPressed: newValue) + } + } + + internal var isEnabled: Bool { + get { + self.viewModel.ratingState.isEnabled + } + set { + self.viewModel.updateState(isEnabled: newValue) + } + } + + internal var ratingStarViews : [StarUIView] { self.stackView.arrangedSubviews.compactMap { view in + return view as? StarUIView + } + } + // MARK: - Scaled metrics @ScaledUIMetric private var borderWidth: CGFloat @ScaledUIMetric private var ratingSize: CGFloat @@ -212,6 +231,7 @@ public class RatingDisplayUIView: UIView { view.borderColor = colors.strokeColor.uiColor view.fillColor = colors.fillColor.uiColor } + self.layer.opacity = Float(colors.opacity) } private func didUpdate(size: RatingSizeAttributes) { diff --git a/core/Sources/Components/Rating/View/UIKit/RatingInputUIView.swift b/core/Sources/Components/Rating/View/UIKit/RatingInputUIView.swift new file mode 100644 index 000000000..1d8a8eeb6 --- /dev/null +++ b/core/Sources/Components/Rating/View/UIKit/RatingInputUIView.swift @@ -0,0 +1,194 @@ +// +// RatingInputUIView.swift +// SparkCore +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Combine +import Foundation +import UIKit + +/// A rating input for setting a rating value. +/// There are three possibilities to receive the changed value: +/// 1. Subscribing to the publisher +/// 2. Adding a value changed target action +/// 3. Setting the delegate +public final class RatingInputUIView: UIControl { + + // MARK: - Properties + /// The current theme + public var theme: Theme { + get { + return self.ratingDisplay.theme + } + set { + self.ratingDisplay.theme = newValue + } + } + + /// The current intent + public var intent: RatingIntent { + get { + return self.ratingDisplay.intent + } + set { + self.ratingDisplay.intent = newValue + } + } + + /// A Boolean value indicating whether the control is in the enabled state. + public override var isEnabled: Bool { + didSet { + self.ratingDisplay.isEnabled = self.isEnabled + } + } + + public override var isHighlighted: Bool { + set { + self.ratingDisplay.isPressed = newValue + } + get { + return self.ratingDisplay.isPressed + } + } + + /// The current rating value. + /// It is expected, that this value is in a range between 0 and 5 + public var rating: CGFloat { + didSet { + guard ratingDisplay.rating != self.rating else { return } + ratingDisplay.rating = self.rating + } + } + + /// Changes to the rating by user interactions will be published. Only changed values will be published + public var publisher: any Publisher { + return self.subject + } + + /// A delegate which is called on rating changes by user interaction + public weak var delegate: RatingInputUIViewDelegate? + + // MARK: - Private properties + private let ratingDisplay: RatingDisplayUIView + private var subject = PassthroughSubject() + private var lastSelectedIndex: Int? + + // MARK: - Initializer + /// Init + /// - Parameters + /// - theme: the current theme + /// - intent: the current intent defining the color + /// - rating: the current rating. This should be a value in the range between 0...5. The default value is 0 + /// - configuration: The star configuration, the default is `default` + public init( + theme: Theme, + intent: RatingIntent, + rating: CGFloat = 0.0, + configuration: StarConfiguration = .default + ) { + self.ratingDisplay = RatingDisplayUIView( + theme: theme, + intent: intent, + count: .five, + size: .input, + rating: rating, + fillMode: .full, + configuration: configuration + ) + + self.rating = rating + super.init(frame: .zero) + self.setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: - Handle touch events + public override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + return self.handleTouch(touch, with: event) + } + + public override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + return self.handleTouch(touch, with: event) + } + + public override func endTracking(_ touch: UITouch?, with event: UIEvent?) { + + guard let location = touch?.location(in: self) else { + self.ratingStarHighlightCancelled() + return + } + + if !self.bounds.contains(location) { + self.ratingStarHighlightCancelled() + } else if let index = self.ratingDisplay.ratingStarViews.index(closestTo: location) { + self.ratingStarSelected(index) + } else if let index = self.lastSelectedIndex { + self.ratingStarSelected(index) + } else { + self.ratingStarHighlightCancelled() + } + + self.lastSelectedIndex = nil + } + + // MARK: - Private functions + + // MARK: - View setup + private func setupView() { + self.ratingDisplay.isUserInteractionEnabled = false + self.addSubviewSizedEqually(self.ratingDisplay) + self.accessibilityIdentifier = RatingInputAccessibilityIdentifier.identifier + } + + // MARK: - Handling touch actions + private func handleTouch(_ touch: UITouch, with event: UIEvent?) -> Bool { + + let location = touch.location(in: self) + + if !self.frame.contains(location) { + if !self.isHighlighted { + self.ratingStarHighlightCancelled() + } + return true + } + + guard let index = self.ratingDisplay.ratingStarViews.index(closestTo: location) else { + if !self.isHighlighted { + self.ratingStarHighlightCancelled() + } + return true + } + + self.lastSelectedIndex = index + self.ratingStarHighlighted(index) + + return true + } + + private func ratingStarSelected(_ index: Int) { + let rating = CGFloat(index + 1) + + guard rating != self.rating else { return } + + self.rating = rating + + self.subject.send(rating) + self.sendActions(for: .valueChanged) + self.delegate?.rating(self, didChangeRating: rating) + } + + private func ratingStarHighlighted(_ index: Int) { + let rating = CGFloat(index + 1) + self.ratingDisplay.rating = rating + } + + private func ratingStarHighlightCancelled() { + self.ratingDisplay.rating = self.rating + } +} diff --git a/core/Sources/Components/Rating/View/UIKit/RatingInputUIViewDelegate.swift b/core/Sources/Components/Rating/View/UIKit/RatingInputUIViewDelegate.swift new file mode 100644 index 000000000..a9ca82f7f --- /dev/null +++ b/core/Sources/Components/Rating/View/UIKit/RatingInputUIViewDelegate.swift @@ -0,0 +1,17 @@ +// +// RatingInputUIViewDelegate.swift +// SparkCore +// +// Created by Michael Zimmermann on 29.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +public protocol RatingInputUIViewDelegate: AnyObject { + /// The rating value was changed. + /// - Parameters: + /// - rating: The updated rating input. + /// - rating: The new rating value. + func rating(_ rating: RatingInputUIView, didChangeRating rating: CGFloat) +} diff --git a/core/Sources/Components/Rating/View/UIKit/RatingInputUIViewSnapshotTests.swift b/core/Sources/Components/Rating/View/UIKit/RatingInputUIViewSnapshotTests.swift new file mode 100644 index 000000000..04d9dfc2e --- /dev/null +++ b/core/Sources/Components/Rating/View/UIKit/RatingInputUIViewSnapshotTests.swift @@ -0,0 +1,50 @@ +// +// RatingInputUIViewSnapshotTests.swift +// SparkCoreSnapshotTests +// +// Created by Michael Zimmermann on 30.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import UIKit + +@testable import SparkCore + +final class RatingInputUIViewSnapshotTests: UIKitComponentSnapshotTestCase { + + // MARK: - Properties + + private let theme: Theme = SparkTheme.shared + + // MARK: - Tests + + func test() { + let scenarios = RatingInputScenarioSnapshotTests.allCases + + for scenario in scenarios { + let configurations = scenario.configuration(isSwiftUIComponent: false) + for configuration in configurations { + let view = RatingInputUIView( + theme: self.theme, + intent: .main, + rating: configuration.rating + ) + + if configuration.state == .disabled { + view.isEnabled = false + } else if configuration.state == .pressed { + view.isHighlighted = true + } + + view.backgroundColor = UIColor.lightGray + + self.assertSnapshot( + matching: view, + modes: configuration.modes, + sizes: configuration.sizes, + testName: configuration.testName() + ) + } + } + } +} diff --git a/core/Sources/Components/Rating/View/UIKit/StarUIView.swift b/core/Sources/Components/Rating/View/UIKit/StarUIView.swift index 14a45544a..d9bbd96cf 100644 --- a/core/Sources/Components/Rating/View/UIKit/StarUIView.swift +++ b/core/Sources/Components/Rating/View/UIKit/StarUIView.swift @@ -250,17 +250,3 @@ private extension UIColor { return "\(red)-\(green)-\(blue)-\(alpha)" } } - -private extension CGRect { - var centerX: CGFloat { - return (self.minX + self.maxX)/2 - } - - var centerY: CGFloat { - return (self.minY + self.maxY)/2 - } - - var center: CGPoint { - return CGPoint(x: self.centerX, y: self.centerY) - } -} diff --git a/spark/Demo/Classes/View/Components/ComponentsViewController.swift b/spark/Demo/Classes/View/Components/ComponentsViewController.swift index e618ec654..85735c6a2 100644 --- a/spark/Demo/Classes/View/Components/ComponentsViewController.swift +++ b/spark/Demo/Classes/View/Components/ComponentsViewController.swift @@ -88,12 +88,14 @@ extension ComponentsViewController { viewController = ProgressBarComponentUIViewController.build() case .radioButton: viewController = RadioButtonComponentUIViewController.build() - case .ratingStar: - viewController = StarComponentViewController.build() case .ratingDisplay: viewController = RatingDisplayComponentViewController.build() + case .ratingInput: + viewController = RatingInputComponentViewController.build() case .spinner: viewController = SpinnerComponentUIViewController.build() + case .star: + viewController = StarComponentViewController.build() case .switchButton: viewController = SwitchComponentUIViewController.build() case .tab: @@ -124,9 +126,10 @@ private extension ComponentsViewController { case progressBarIndeterminate case progressBarSingle case radioButton - case ratingStar case ratingDisplay + case ratingInput case spinner + case star case switchButton case tab case tag diff --git a/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentUIView.swift b/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentUIView.swift new file mode 100644 index 000000000..e7e37d59f --- /dev/null +++ b/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentUIView.swift @@ -0,0 +1,75 @@ +// +// RatingInputComponentUIView.swift +// SparkDemo +// +// Created by Michael Zimmermann on 29.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +import UIKit +import Combine +import SparkCore +import Spark + +final class RatingInputComponentUIView: ComponentUIView { + + private var componentView: RatingInputUIView! + + // MARK: - Properties + private let viewModel: RatingInputComponentUIViewModel + private var cancellables: Set = [] + private var sizeConstraints = [NSLayoutConstraint]() + + // MARK: - Initializer + init(viewModel: RatingInputComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = Self.makeRatingInputView(viewModel: viewModel) + + super.init(viewModel: viewModel, componentView: componentView) + + self.setupSubscriptions() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private static func makeRatingInputView(viewModel: RatingInputComponentUIViewModel) -> RatingInputUIView { + let view = RatingInputUIView( + theme: viewModel.theme, + intent: viewModel.intent, + rating: viewModel.rating + ) + return view + } + + private func setupSubscriptions() { + self.viewModel.$theme.subscribe(in: &self.cancellables) { [weak self] theme in + guard let self = self else { return } + let themes = self.viewModel.themes + let themeTitle: String? = theme is SparkTheme ? themes.first?.title : themes.last?.title + + self.viewModel.themeConfigurationItemViewModel.buttonTitle = themeTitle + self.viewModel.configurationViewModel.update(theme: theme) + + self.componentView.theme = theme + } + + self.viewModel.$intent.subscribe(in: &self.cancellables) { [weak self] intent in + guard let self = self else { return } + self.viewModel.intentConfigurationItemViewModel.buttonTitle = intent.name + self.componentView.intent = intent + } + + self.viewModel.$rating.subscribe(in: &self.cancellables) { [weak self] rating in + guard let self = self else { return } + self.componentView.rating = rating + } + + self.viewModel.$isDisabled.subscribe(in: &self.cancellables) { [weak self] disabled in + self?.componentView.isEnabled = !disabled + } + } +} diff --git a/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentUIViewModel.swift b/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentUIViewModel.swift new file mode 100644 index 000000000..16f21dde5 --- /dev/null +++ b/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentUIViewModel.swift @@ -0,0 +1,114 @@ +// +// RatingInputComponentUIViewModel.swift +// SparkDemo +// +// Created by Michael Zimmermann on 29.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Foundation + +import Combine +import Spark +import SparkCore +import UIKit + +final class RatingInputComponentUIViewModel: ComponentUIViewModel { + + // MARK: - Private Properties + private var showThemeSheetSubject: PassthroughSubject<[ThemeCellModel], Never> = .init() + private var showIntentSheetSubject: PassthroughSubject<[RatingIntent], Never> = .init() + var themes = ThemeCellModel.themes + + // MARK: - Items Properties + lazy var themeConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Theme", + type: .button, + target: (source: self, action: #selector(self.presentThemeSheet)) + ) + }() + + lazy var intentConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Intent", + type: .button, + target: (source: self, action: #selector(self.presentIntentSheet)) + ) + }() + + lazy var ratingConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Rating", + type: .rangeSelector( + selected: Int(self.rating), + range: 1...5 + ), + target: (source: self, action: #selector(self.ratingChanged))) + }() + + lazy var disableConfigurationItemViewModel: ComponentsConfigurationItemUIViewModel = { + return .init( + name: "Disable", + type: .checkbox(title: "", isOn: self.isDisabled), + target: (source: self, action: #selector(self.disableChanged(_:)))) + }() + + + var showThemeSheet: AnyPublisher<[ThemeCellModel], Never> { + showThemeSheetSubject + .eraseToAnyPublisher() + } + + var showIntentSheet: AnyPublisher<[RatingIntent], Never> { + showIntentSheetSubject + .eraseToAnyPublisher() + } + + // MARK: - Published Properties + @Published var theme: Theme + @Published var intent: RatingIntent + @Published var rating: CGFloat = 1.0 + @Published var count: RatingStarsCount = .five + @Published var isDisabled: Bool = false + + override func configurationItemsViewModel() -> [ComponentsConfigurationItemUIViewModel] { + return [ + self.themeConfigurationItemViewModel, + self.intentConfigurationItemViewModel, + self.ratingConfigurationItemViewModel, + self.disableConfigurationItemViewModel + ] + } + + // MARK: Initializer + init( + theme: Theme, + intent: RatingIntent = .main + ) { + self.theme = theme + self.intent = intent + + super.init(identifier: "Rating Input") + } +} + +// MARK: - Navigation +extension RatingInputComponentUIViewModel { + + @objc func presentThemeSheet() { + self.showThemeSheetSubject.send(themes) + } + + @objc func presentIntentSheet() { + self.showIntentSheetSubject.send(RatingIntent.allCases) + } + + @objc func ratingChanged(_ control: NumberSelector) { + self.rating = CGFloat(control.selectedValue) + } + + @objc func disableChanged(_ isSelected: Any?) { + self.isDisabled = isTrue(isSelected) + } +} diff --git a/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentViewController.swift b/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentViewController.swift new file mode 100644 index 000000000..0f2fcd1cd --- /dev/null +++ b/spark/Demo/Classes/View/Components/Rating/UIKit/RatingInputComponentViewController.swift @@ -0,0 +1,103 @@ +// +// RatingInputComponentViewController.swift +// SparkDemo +// +// Created by Michael Zimmermann on 29.11.23. +// Copyright © 2023 Adevinta. All rights reserved. +// + +import Combine +import Spark +import SwiftUI +import UIKit +import SparkCore + +final class RatingInputComponentViewController: UIViewController { + + // MARK: - Published Properties + @ObservedObject private var themePublisher = SparkThemePublisher.shared + + // MARK: - Properties + let componentView: RatingInputComponentUIView + let viewModel: RatingInputComponentUIViewModel + private var cancellables: Set = [] + + // MARK: - Initializer + init(viewModel: RatingInputComponentUIViewModel) { + self.viewModel = viewModel + self.componentView = RatingInputComponentUIView(viewModel: viewModel) + super.init(nibName: nil, bundle: nil) + + self.componentView.viewController = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func loadView() { + super.loadView() + view = componentView + } + + // MARK: - ViewDidLoad + override func viewDidLoad() { + super.viewDidLoad() + self.navigationItem.title = "Rating Input" + addPublisher() + } + + // 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) + } + } +} + +// MARK: - Builder +extension RatingInputComponentViewController { + + static func build() -> RatingInputComponentViewController { + let viewModel = RatingInputComponentUIViewModel(theme: SparkThemePublisher.shared.theme) + let viewController = RatingInputComponentViewController(viewModel: viewModel) + return viewController + } +} + +// MARK: - Navigation +extension RatingInputComponentViewController { + + 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: [RatingIntent]) { + let actionSheet = SparkActionSheet.init( + values: intents, + texts: intents.map { $0.name }) { intent in + self.viewModel.intent = intent + } + self.present(actionSheet, animated: true) + } +} diff --git a/spark/Demo/Classes/View/Components/Rating/UIKit/StarComponentViewController.swift b/spark/Demo/Classes/View/Components/Rating/UIKit/StarComponentViewController.swift index a8c90151e..c309e4a03 100644 --- a/spark/Demo/Classes/View/Components/Rating/UIKit/StarComponentViewController.swift +++ b/spark/Demo/Classes/View/Components/Rating/UIKit/StarComponentViewController.swift @@ -41,7 +41,7 @@ final class StarComponentViewController: UIViewController { // MARK: - ViewDidLoad override func viewDidLoad() { super.viewDidLoad() - self.navigationItem.title = "Star Rating" + self.navigationItem.title = "Star (Not a component)" addPublisher() }