diff --git a/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewController.swift b/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewController.swift index e2b80ac483..ea659dace3 100644 --- a/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewController.swift +++ b/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewController.swift @@ -6,6 +6,10 @@ protocol PledgePaymentPlansViewControllerDelegate: AnyObject { _ viewController: PledgePaymentPlansViewController, didSelectPaymentPlan paymentPlan: PledgePaymentPlansType ) + func pledgePaymentPlansViewController( + _ viewController: PledgePaymentPlansViewController, + didTapTermsOfUseWith helpType: HelpType + ) } final class PledgePaymentPlansViewController: UIViewController { @@ -39,11 +43,11 @@ final class PledgePaymentPlansViewController: UIViewController { self.pledgeInFullOption.delegate = self self.pledgeOverTimeOption.delegate = self - self.rootStackView.addArrangedSubviews([ + self.rootStackView.addArrangedSubviews( self.pledgeInFullOption, self.separatorView, self.pledgeOverTimeOption - ]) + ) } private func setupConstraints() { @@ -82,11 +86,15 @@ final class PledgePaymentPlansViewController: UIViewController { self.pledgeInFullOption.configureWith(value: PledgePaymentPlanOptionData( type: .pledgeInFull, - selectedType: data.selectedPlan + selectedType: data.selectedPlan, + paymentIncrements: data.paymentIncrements, + project: data.project )) self.pledgeOverTimeOption.configureWith(value: PledgePaymentPlanOptionData( type: .pledgeOverTime, - selectedType: data.selectedPlan + selectedType: data.selectedPlan, + paymentIncrements: data.paymentIncrements, + project: data.project )) } @@ -97,6 +105,13 @@ final class PledgePaymentPlansViewController: UIViewController { self.delegate?.pledgePaymentPlansViewController(self, didSelectPaymentPlan: paymentPlan) } + self.viewModel.outputs.notifyDelegateTermsOfUseTapped + .observeForUI() + .observeValues { [weak self] helpType in + guard let self = self else { return } + + self.delegate?.pledgePaymentPlansViewController(self, didTapTermsOfUseWith: helpType) + } } // MARK: - Configuration @@ -115,6 +130,13 @@ extension PledgePaymentPlansViewController: PledgePaymentPlanOptionViewDelegate ) { self.viewModel.inputs.didSelectPlanType(paymentPlanType) } + + func pledgePaymentPlansViewController( + _: PledgePaymentPlanOptionView, + didTapTermsOfUseWith helpType: HelpType + ) { + self.viewModel.inputs.didTapTermsOfUse(with: helpType) + } } // MARK: Styles diff --git a/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewControllerTest.swift b/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewControllerTest.swift index 4261a9d1a2..aa06a22fc9 100644 --- a/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewControllerTest.swift +++ b/Kickstarter-iOS/Features/PledgeOverTime/Controller/PledgePaymentPlansViewControllerTest.swift @@ -1,4 +1,5 @@ @testable import Kickstarter_Framework +@testable import KsApi @testable import Library import Prelude import SnapshotTesting @@ -19,11 +20,12 @@ final class PledgePaymentPlansViewControllerTest: TestCase { } func testView_PledgeInFullSelected() { + let project = Project.template orthogonalCombos([Language.en], [Device.pad, Device.phone4_7inch]).forEach { language, device in withEnvironment(language: language) { let controller = PledgePaymentPlansViewController.instantiate() - let data = PledgePaymentPlansAndSelectionData(selectedPlan: .pledgeInFull) + let data = PledgePaymentPlansAndSelectionData(selectedPlan: .pledgeInFull, project: project) controller.configure(with: data) let (parent, _) = traitControllers(device: device, orientation: .portrait, child: controller) @@ -37,20 +39,45 @@ final class PledgePaymentPlansViewControllerTest: TestCase { } func testView_PledgeOverTimeSelected() { + let project = Project.template + let testIncrements = testPledgePaymentIncrement() orthogonalCombos([Language.en], [Device.pad, Device.phone4_7inch]).forEach { language, device in withEnvironment(language: language) { let controller = PledgePaymentPlansViewController.instantiate() - let data = PledgePaymentPlansAndSelectionData(selectedPlan: .pledgeOverTime) + let data = PledgePaymentPlansAndSelectionData( + selectedPlan: .pledgeOverTime, + increments: testIncrements, + project: project + ) controller.configure(with: data) + controller.pledgePaymentPlanOptionView( + PledgePaymentPlanOptionView(), + didSelectPlanType: .pledgeOverTime + ) + let (parent, _) = traitControllers(device: device, orientation: .portrait, child: controller) parent.view.frame.size.height = 400 - self.scheduler.advance(by: .seconds(1)) + self.scheduler.advance(by: .seconds(3)) assertSnapshot(matching: parent.view, as: .image, named: "lang_\(language)_device_\(device)") } } } } + +private func testPledgePaymentIncrement() -> [PledgePaymentIncrement] { + var increments: [PledgePaymentIncrement] = [] + var timeStamp = TimeInterval(1_733_931_903) + for _ in 1...4 { + timeStamp += 30 * 24 * 60 * 60 + increments.append(PledgePaymentIncrement( + amount: PledgePaymentIncrementAmount(amount: 250.0, currency: "USD"), + scheduledCollection: timeStamp + )) + } + + return increments +} diff --git a/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_pad.png b/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_pad.png index 31e2f82b76..33017130f3 100644 Binary files a/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_pad.png and b/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_pad.png differ diff --git a/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_phone4_7inch.png b/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_phone4_7inch.png index 6a973a0d9d..b173632d12 100644 Binary files a/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_phone4_7inch.png and b/Kickstarter-iOS/Features/PledgeOverTime/Controller/__Snapshots__/PledgePaymentPlansViewControllerTest/testView_PledgeOverTimeSelected.lang_en_device_phone4_7inch.png differ diff --git a/Kickstarter-iOS/Features/PledgeOverTime/Views/PledgePaymentPlanOptionView.swift b/Kickstarter-iOS/Features/PledgeOverTime/Views/PledgePaymentPlanOptionView.swift index e817b8195f..4182f534a3 100644 --- a/Kickstarter-iOS/Features/PledgeOverTime/Views/PledgePaymentPlanOptionView.swift +++ b/Kickstarter-iOS/Features/PledgeOverTime/Views/PledgePaymentPlanOptionView.swift @@ -1,11 +1,27 @@ import Library import UIKit +private enum Constants { + /// Spacing & Padding + public static let contentInsets = NSDirectionalEdgeInsets(top: 1.0, leading: 0, bottom: 1.0, trailing: 0) + public static let defaultPaddingSpacing = Styles.grid(2) + public static let detailsStackViewSpacing = Styles.grid(6) + public static let incrementStackViewSpacing = Styles.gridHalf(1) + public static let optionDescriptorStackViewSpacing = Styles.grid(1) + + /// Size + public static let selectionIndicatorImageWith = Styles.grid(4) +} + protocol PledgePaymentPlanOptionViewDelegate: AnyObject { func pledgePaymentPlanOptionView( _ optionView: PledgePaymentPlanOptionView, didSelectPlanType paymentPlanType: PledgePaymentPlansType ) + func pledgePaymentPlansViewController( + _ optionView: PledgePaymentPlanOptionView, + didTapTermsOfUseWith helpType: HelpType + ) } final class PledgePaymentPlanOptionView: UIView { @@ -16,6 +32,8 @@ final class PledgePaymentPlanOptionView: UIView { private lazy var titleLabel = { UILabel(frame: .zero) }() private lazy var subtitleLabel = { UILabel(frame: .zero) }() private lazy var selectionIndicatorImageView: UIImageView = { UIImageView(frame: .zero) }() + private lazy var termsOfUseButton: UIButton = { UIButton(frame: .zero) }() + private lazy var paymentIncrementsStackView: UIStackView = { UIStackView(frame: .zero) }() private let viewModel: PledgePaymentPlansOptionViewModelType = PledgePaymentPlansOptionViewModel() @@ -27,7 +45,7 @@ final class PledgePaymentPlanOptionView: UIView { self.bindViewModel() self.configureSubviews() self.setupConstraints() - self.configureTapGesture() + self.configureTapGestureAndActions() } @available(*, unavailable) @@ -43,7 +61,21 @@ final class PledgePaymentPlanOptionView: UIView { self.contentView.addSubview(self.selectionIndicatorImageView) self.contentView.addSubview(self.optionDescriptorStackView) - self.optionDescriptorStackView.addArrangedSubviews([self.titleLabel, self.subtitleLabel]) + self.optionDescriptorStackView.addArrangedSubviews( + self.titleLabel, + self.subtitleLabel, + self.termsOfUseButton, + self.paymentIncrementsStackView + ) + + // TODO: add strings translations [MBL-1860](https://kickstarter.atlassian.net/browse/MBL-1860) + self.termsOfUseButton.setAttributedTitle( + NSAttributedString( + string: "See our Terms of Use", + attributes: [NSAttributedString.Key.font: UIFont.ksr_caption1()] + ), + for: .normal + ) } private func setupConstraints() { @@ -58,16 +90,28 @@ final class PledgePaymentPlanOptionView: UIView { self.subtitleLabel.setContentHuggingPriority(.required, for: .vertical) NSLayoutConstraint.activate([ - self.contentView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: Styles.grid(2)), - self.contentView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -Styles.grid(2)), - self.contentView.topAnchor.constraint(equalTo: self.topAnchor, constant: Styles.grid(2)), - self.contentView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -Styles.grid(2)) + self.contentView.leadingAnchor.constraint( + equalTo: self.leadingAnchor, + constant: Constants.defaultPaddingSpacing + ), + self.contentView.trailingAnchor.constraint( + equalTo: self.trailingAnchor, + constant: -Constants.defaultPaddingSpacing + ), + self.contentView.topAnchor.constraint( + equalTo: self.topAnchor, + constant: Constants.defaultPaddingSpacing + ), + self.contentView.bottomAnchor.constraint( + equalTo: self.bottomAnchor, + constant: -Constants.defaultPaddingSpacing + ) ]) NSLayoutConstraint.activate([ self.optionDescriptorStackView.leadingAnchor.constraint( equalTo: self.selectionIndicatorImageView.trailingAnchor, - constant: Styles.grid(2) + constant: Constants.defaultPaddingSpacing ), self.optionDescriptorStackView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), self.optionDescriptorStackView.topAnchor.constraint(equalTo: self.contentView.topAnchor), @@ -77,19 +121,28 @@ final class PledgePaymentPlanOptionView: UIView { NSLayoutConstraint.activate([ self.selectionIndicatorImageView.topAnchor.constraint(equalTo: self.contentView.topAnchor), self.selectionIndicatorImageView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), - self.selectionIndicatorImageView.widthAnchor.constraint(equalToConstant: Styles.grid(4)), + self.selectionIndicatorImageView.widthAnchor + .constraint(equalToConstant: Constants.selectionIndicatorImageWith), self.selectionIndicatorImageView.heightAnchor.constraint( equalTo: self.titleLabel.heightAnchor, multiplier: 1.0 ) ]) + + self.termsOfUseButton.setContentHuggingPriority(.required, for: .horizontal) } - private func configureTapGesture() { + private func configureTapGestureAndActions() { self.addGestureRecognizer(UITapGestureRecognizer( target: self, action: #selector(self.onOptionTapped) )) + + self.termsOfUseButton.addTarget( + self, + action: #selector(self.onTermsOfUseButtonTapped), + for: .touchUpInside + ) } // MARK: - Styles @@ -98,9 +151,13 @@ final class PledgePaymentPlanOptionView: UIView { super.bindStyles() applyOptionDescriptorStackViewStyle(self.optionDescriptorStackView) + self.optionDescriptorStackView.setCustomSpacing(0, after: self.subtitleLabel) + applyTitleLabelStyle(self.titleLabel) applySubtitleLabelStyle(self.subtitleLabel) applySelectionIndicatorImageViewStyle(self.selectionIndicatorImageView) + applyTermsOfUseStyle(self.termsOfUseButton) + applyPaymentIncrementsStackViewStyle(self.paymentIncrementsStackView) } // MARK: - View model @@ -126,6 +183,25 @@ final class PledgePaymentPlanOptionView: UIView { self.delegate?.pledgePaymentPlanOptionView(self, didSelectPlanType: paymentPlan) } + + self.viewModel.outputs.notifyDelegateTermsOfUseTapped + .observeForUI() + .observeValues { [weak self] helpType in + guard let self = self else { return } + + self.delegate?.pledgePaymentPlansViewController(self, didTapTermsOfUseWith: helpType) + } + + self.termsOfUseButton.rac.hidden = self.viewModel.outputs.termsOfUseButtonHidden + self.paymentIncrementsStackView.rac.hidden = self.viewModel.outputs.paymentIncrementsHidden + + self.viewModel.outputs.paymentIncrements + .observeForUI() + .observeValues { [weak self] increments in + guard let self = self else { return } + + self.setupIncrementsStackView(with: increments) + } } func configureWith(value: PledgePaymentPlanOptionData) { @@ -141,13 +217,66 @@ final class PledgePaymentPlanOptionView: UIView { @objc private func onOptionTapped() { self.viewModel.inputs.optionTapped() } + + @objc private func onTermsOfUseButtonTapped() { + self.viewModel.inputs.termsOfUseTapped() + } + + // MARK: - Functions + + private func setupIncrementsStackView(with increments: [PledgePaymentIncrementFormatted]) { + var dateLabels: [UILabel] = [] + increments.forEach { increment in + let incrementStackView = UIStackView() + applyIncrementStackViewStyle(incrementStackView) + + let chargeNumberLabel = UILabel() + applyIncrementChargeNumberLabelStyle(chargeNumberLabel) + chargeNumberLabel.text = increment.incrementChargeNumber + + let detailsStackView = UIStackView() + applyIncrementDetailsStackViewStyle(detailsStackView) + + let dateLabel = UILabel() + applyIncrementDateLabelStyle(dateLabel) + dateLabel.text = increment.scheduledCollection + dateLabels.append(dateLabel) + + let amountLabel = UILabel() + applyIncrementDateLabelStyle(amountLabel) + amountLabel.text = increment.amount + amountLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + detailsStackView.addArrangedSubviews(dateLabel, amountLabel) + incrementStackView.addArrangedSubviews(chargeNumberLabel, detailsStackView) + + self.paymentIncrementsStackView.addArrangedSubview(incrementStackView) + } + + // Ensures all dateLabels have equal width to maintain alignment of amountLabel. + // This fixes an issue where dates with one-digit days (e.g., "4 Jan 2025") + // and two-digit days (e.g., "14 Feb 2025") caused misalignment of the amountLabel. + // By constraining each label's width to the first dateLabel's width, we guarantee consistent alignment. + if let firstDateLabel = dateLabels.first { + dateLabels.forEach { label in + label.widthAnchor.constraint(equalTo: firstDateLabel.widthAnchor).isActive = true + } + } + } } // MARK: - Styles helper private func applyOptionDescriptorStackViewStyle(_ stackView: UIStackView) { stackView.axis = .vertical - stackView.spacing = Styles.grid(1) + stackView.spacing = Constants.optionDescriptorStackViewSpacing + stackView.alignment = .leading +} + +private func applyPaymentIncrementsStackViewStyle(_ stackView: UIStackView) { + stackView.axis = .vertical + stackView.spacing = Constants.defaultPaddingSpacing + stackView.alignment = .leading } private func applyTitleLabelStyle(_ label: UILabel) { @@ -155,6 +284,7 @@ private func applyTitleLabelStyle(_ label: UILabel) { label.adjustsFontForContentSizeCategory = true label.numberOfLines = 0 label.font = UIFont.ksr_subhead().bolded + label.textColor = .ksr_black } private func applySubtitleLabelStyle(_ label: UILabel) { @@ -168,3 +298,39 @@ private func applySubtitleLabelStyle(_ label: UILabel) { private func applySelectionIndicatorImageViewStyle(_ imageView: UIImageView) { imageView.contentMode = .center } + +private func applyTermsOfUseStyle(_ button: UIButton) { + button.configuration = { + var config = UIButton.Configuration.borderless() + config.contentInsets = Constants.contentInsets + config.baseForegroundColor = .ksr_create_700 + return config + }() + + button.contentHorizontalAlignment = .leading +} + +private func applyIncrementStackViewStyle(_ stackView: UIStackView) { + stackView.axis = .vertical + stackView.spacing = Constants.incrementStackViewSpacing +} + +private func applyIncrementDetailsStackViewStyle(_ stackview: UIStackView) { + stackview.axis = .horizontal + stackview.spacing = Constants.detailsStackViewSpacing +} + +private func applyIncrementChargeNumberLabelStyle(_ label: UILabel) { + label.font = UIFont.ksr_footnote().bolded + label.textColor = .ksr_black + label.textAlignment = .left + label.adjustsFontForContentSizeCategory = true + label.setContentCompressionResistancePriority(.required, for: .vertical) +} + +private func applyIncrementDateLabelStyle(_ label: UILabel) { + label.font = UIFont.ksr_footnote() + label.textColor = .ksr_support_400 + label.textAlignment = .left + label.adjustsFontForContentSizeCategory = true +} diff --git a/Kickstarter-iOS/Features/PledgeView/Controllers/NoShippingPledgeViewController.swift b/Kickstarter-iOS/Features/PledgeView/Controllers/NoShippingPledgeViewController.swift index c6f5d289de..3436f66708 100644 --- a/Kickstarter-iOS/Features/PledgeView/Controllers/NoShippingPledgeViewController.swift +++ b/Kickstarter-iOS/Features/PledgeView/Controllers/NoShippingPledgeViewController.swift @@ -735,4 +735,12 @@ extension NoShippingPledgeViewController: PledgePaymentPlansViewControllerDelega // TODO: Implement the necessary functionality once the ticket [MBL-1853] is resolved debugPrint("pledgePaymentPlansViewController:didSelectPaymentPlan: \(paymentPlan)") } + + func pledgePaymentPlansViewController( + _: PledgePaymentPlansViewController, + didTapTermsOfUseWith helpType: HelpType + ) { + self.paymentMethodsViewController.cancelModalPresentation(true) + self.viewModel.inputs.termsOfUseTapped(with: helpType) + } } diff --git a/Library/Extensions/UIStackView+Helper.swift b/Library/Extensions/UIStackView+Helper.swift index 89f7883135..4c6878588a 100644 --- a/Library/Extensions/UIStackView+Helper.swift +++ b/Library/Extensions/UIStackView+Helper.swift @@ -6,7 +6,7 @@ extension UIStackView { /// - Parameter subviews: An array of `UIView` objects to be added as arranged subviews. /// - Note: This method iterates through the provided views and calls `addArrangedSubview(_:)` /// on each, adding them to the stack view in the order they appear in the array. - public func addArrangedSubviews(_ subviews: [UIView]) { + public func addArrangedSubviews(_ subviews: UIView...) { subviews.forEach(self.addArrangedSubview) } } diff --git a/Library/ViewModels/NoShippingPledgeViewModel.swift b/Library/ViewModels/NoShippingPledgeViewModel.swift index a9720dbc33..599f4d0b09 100644 --- a/Library/ViewModels/NoShippingPledgeViewModel.swift +++ b/Library/ViewModels/NoShippingPledgeViewModel.swift @@ -98,11 +98,15 @@ public class NoShippingPledgeViewModel: NoShippingPledgeViewModelType, NoShippin } self.pledgeOverTimeConfigData = self.showPledgeOverTimeUI - .map { showPledgeOverTimeUI -> PledgePaymentPlansAndSelectionData? in - guard showPledgeOverTimeUI else { return nil } - - return PledgePaymentPlansAndSelectionData(selectedPlan: .pledgeInFull) - }.skipNil() + .filter { showUI in showUI == true } + .combineLatest(with: project) + .map { _, project -> PledgePaymentPlansAndSelectionData in + PledgePaymentPlansAndSelectionData( + selectedPlan: .pledgeInFull, + increments: mockPledgePaymentIncrement(), + project: project + ) + } self.pledgeAmountViewHidden = context.map { $0.pledgeAmountViewHidden } self.pledgeAmountSummaryViewHidden = Signal.zip(baseReward, context).map { baseReward, context in @@ -1245,3 +1249,20 @@ private func pledgeAmountSummaryViewData( rewardIsLocalPickup: rewardIsLocalPickup ) } + +// TODO: Remove this when implementing the API [MBL-1838](https://kickstarter.atlassian.net/browse/MBL-1838) +private func mockPledgePaymentIncrement() -> [PledgePaymentIncrement] { + var increments: [PledgePaymentIncrement] = [] + #if DEBUG + var timeStamp = Date().timeIntervalSince1970 + for _ in 1...4 { + timeStamp += 30 * 24 * 60 * 60 + increments.append(PledgePaymentIncrement( + amount: PledgePaymentIncrementAmount(amount: 250.0, currency: "USD"), + scheduledCollection: timeStamp + )) + } + #endif + + return increments +} diff --git a/Library/ViewModels/PledgePaymentPlansOptionViewModel.swift b/Library/ViewModels/PledgePaymentPlansOptionViewModel.swift index 1cc0f0aae9..8d978628ba 100644 --- a/Library/ViewModels/PledgePaymentPlansOptionViewModel.swift +++ b/Library/ViewModels/PledgePaymentPlansOptionViewModel.swift @@ -1,13 +1,47 @@ +import Foundation +import KsApi import ReactiveSwift -public typealias PledgePaymentPlanOptionData = ( - type: PledgePaymentPlansType, - selectedType: PledgePaymentPlansType -) +public struct PledgePaymentPlanOptionData: Equatable { + public let type: PledgePaymentPlansType + public var selectedType: PledgePaymentPlansType + // TODO: replece with API model in [MBL-1838](https://kickstarter.atlassian.net/browse/MBL-1838) + public let paymentIncrements: [PledgePaymentIncrement] + public let project: Project + + public init( + type: PledgePaymentPlansType, + selectedType: PledgePaymentPlansType, + paymentIncrements: [PledgePaymentIncrement], + project: Project + ) { + self.type = type + self.selectedType = selectedType + self.paymentIncrements = paymentIncrements + self.project = project + } +} + +public struct PledgePaymentIncrement: Equatable { + public let amount: PledgePaymentIncrementAmount + public let scheduledCollection: TimeInterval +} + +public struct PledgePaymentIncrementAmount: Equatable { + public let amount: Double + public let currency: String +} + +public struct PledgePaymentIncrementFormatted: Equatable { + public var incrementChargeNumber: String + public var amount: String + public var scheduledCollection: String +} public protocol PledgePaymentPlansOptionViewModelInputs { func configureWith(data: PledgePaymentPlanOptionData) func optionTapped() + func termsOfUseTapped() func refreshSelectedType(_ selectedType: PledgePaymentPlansType) } @@ -17,6 +51,10 @@ public protocol PledgePaymentPlansOptionViewModelOutputs { var subtitleLabelHidden: Signal { get } var selectionIndicatorImageName: Signal { get } var notifyDelegatePaymentPlanOptionSelected: Signal { get } + var notifyDelegateTermsOfUseTapped: Signal { get } + var termsOfUseButtonHidden: Signal { get } + var paymentIncrementsHidden: Signal { get } + var paymentIncrements: Signal<[PledgePaymentIncrementFormatted], Never> { get } } public protocol PledgePaymentPlansOptionViewModelType { @@ -38,13 +76,36 @@ public final class PledgePaymentPlansOptionViewModel: } self.titleText = configData.map { getTitleText(by: $0.type) } - self.subtitleText = configData.map { getSubtitleText(by: $0.type) } + self.subtitleText = configData + .map { getSubtitleText(by: $0.type, isSelected: $0.selectedType == $0.type) } self.subtitleLabelHidden = self.subtitleText.map { $0.isEmpty } self.notifyDelegatePaymentPlanOptionSelected = self.optionTappedProperty .signal .withLatest(from: configData) .map { $1.type } + + let isPledgeOverTimeAndSelected = configData.map { + $0.type == .pledgeOverTime && $0.type == $0.selectedType + } + + self.termsOfUseButtonHidden = isPledgeOverTimeAndSelected.negate() + + self.paymentIncrementsHidden = isPledgeOverTimeAndSelected.negate() + + self.paymentIncrements = configData + .filter { $0.type == .pledgeOverTime && $0.selectedType == $0.type } + .map { data in + data.paymentIncrements + .enumerated() + .map { index, increment in + formattedPledgePaymentIncrement(increment, at: index, project: data.project) + } + } + .filter { !$0.isEmpty } + .take(first: 1) + + self.notifyDelegateTermsOfUseTapped = self.termsOfUseTappedProperty.signal.skipNil() } fileprivate let configData = MutableProperty(nil) @@ -61,11 +122,20 @@ public final class PledgePaymentPlansOptionViewModel: self.optionTappedProperty.value = () } + private let termsOfUseTappedProperty = MutableProperty(nil) + public func termsOfUseTapped() { + self.termsOfUseTappedProperty.value = .terms + } + public let selectionIndicatorImageName: Signal public var titleText: ReactiveSwift.Signal public var subtitleText: ReactiveSwift.Signal public var subtitleLabelHidden: Signal public var notifyDelegatePaymentPlanOptionSelected: Signal + public var notifyDelegateTermsOfUseTapped: Signal + public var termsOfUseButtonHidden: Signal + public var paymentIncrementsHidden: Signal + public var paymentIncrements: Signal<[PledgePaymentIncrementFormatted], Never> public var inputs: PledgePaymentPlansOptionViewModelInputs { return self } public var outputs: PledgePaymentPlansOptionViewModelOutputs { return self } @@ -80,9 +150,45 @@ private func getTitleText(by type: PledgePaymentPlansType) -> String { } // TODO: add strings translations [MBL-1860](https://kickstarter.atlassian.net/browse/MBL-1860) -private func getSubtitleText(by type: PledgePaymentPlansType) -> String { +private func getSubtitleText(by type: PledgePaymentPlansType, isSelected: Bool) -> String { switch type { case .pledgeInFull: "" - case .pledgeOverTime: "You will be charged for your pledge over four payments, at no extra cost." + case .pledgeOverTime: { + let subtitle = "You will be charged for your pledge over four payments, at no extra cost." + guard isSelected else { return subtitle } + + return "\(subtitle)\n\nThe first charge will be 24 hours after the project ends successfully, then every 2 weeks until fully paid. When this option is selected no further edits can be made to your pledge." + }() + } +} + +private func formattedPledgePaymentIncrement( + _ increment: PledgePaymentIncrement, + at index: Int, + project: Project +) -> PledgePaymentIncrementFormatted { + PledgePaymentIncrementFormatted(from: increment, index: index, project: project) +} + +private func getDateFormatted(_ timeStamp: TimeInterval) -> String { + Format.date( + secondsInUTC: timeStamp, + dateStyle: .medium, + timeStyle: .none + ) +} + +extension PledgePaymentIncrementFormatted { + init(from increment: PledgePaymentIncrement, index: Int, project: Project) { + let projectCurrencyCountry = projectCountry(forCurrency: project.stats.currency) ?? project.country + + // TODO: add strings translations [MBL-1860](https://kickstarter.atlassian.net/browse/MBL-1860) + self.incrementChargeNumber = "Charge \(index + 1)" + self.amount = Format.currency( + increment.amount.amount, + country: projectCurrencyCountry, + omitCurrencyCode: project.stats.omitUSCurrencyCode + ) + self.scheduledCollection = getDateFormatted(increment.scheduledCollection) } } diff --git a/Library/ViewModels/PledgePaymentPlansOptionViewModelTest.swift b/Library/ViewModels/PledgePaymentPlansOptionViewModelTest.swift index bb78369154..b069e5d189 100644 --- a/Library/ViewModels/PledgePaymentPlansOptionViewModelTest.swift +++ b/Library/ViewModels/PledgePaymentPlansOptionViewModelTest.swift @@ -1,4 +1,5 @@ import Foundation +@testable import KsApi @testable import Library import ReactiveExtensions import ReactiveExtensions_TestHelpers @@ -14,6 +15,9 @@ final class PledgePaymentPlansOptionViewModelTest: TestCase { private var subtitleText = TestObserver() private var subtitleLabelHidden = TestObserver() private var notifyDelegatePaymentPlanOptionSelected = TestObserver() + private var paymentIncrementsHidden = TestObserver() + private var termsOfUseButtonHidden = TestObserver() + private var paymentIncrements = TestObserver<[PledgePaymentIncrementFormatted], Never>() // MARK: Const @@ -21,6 +25,8 @@ final class PledgePaymentPlansOptionViewModelTest: TestCase { private let pledgeOverTimeTitle = "Pledge Over Time" private let pledgeOverTimeSubtitle = "You will be charged for your pledge over four payments, at no extra cost." + private let pledgeOverTimeFullSubtitle = + "You will be charged for your pledge over four payments, at no extra cost.\n\nThe first charge will be 24 hours after the project ends successfully, then every 2 weeks until fully paid. When this option is selected no further edits can be made to your pledge." private let selectedImageName = "icon-payment-method-selected" private let unselectedImageName = "icon-payment-method-unselected" @@ -35,56 +41,114 @@ final class PledgePaymentPlansOptionViewModelTest: TestCase { self.vm.outputs.subtitleLabelHidden.observe(self.subtitleLabelHidden.observer) self.vm.outputs.notifyDelegatePaymentPlanOptionSelected .observe(self.notifyDelegatePaymentPlanOptionSelected.observer) + self.vm.outputs.paymentIncrementsHidden.observe(self.paymentIncrementsHidden.observer) + self.vm.outputs.termsOfUseButtonHidden.observe(self.termsOfUseButtonHidden.observer) + self.vm.outputs.paymentIncrements.observe(self.paymentIncrements.observer) } // MARK: Test cases func testPaymentPlanOption_PledgeinFull_Selected() { - let data = PledgePaymentPlanOptionData(type: .pledgeInFull, selectedType: .pledgeInFull) + let data = PledgePaymentPlanOptionData( + type: .pledgeInFull, + selectedType: .pledgeInFull, + paymentIncrements: mockPaymentIncrements(), + project: Project.template + ) self.vm.inputs.configureWith(data: data) self.titleText.assertValue(self.pledgeInFullTitle) self.subtitleText.assertValue("") self.subtitleLabelHidden.assertValue(true) + self.paymentIncrementsHidden.assertValue(true) self.selectionIndicatorImageName.assertValue(self.selectedImageName) + self.paymentIncrements.assertValues([]) } func testPaymentPlanOption_PledgeinFull_Unselected() { - let data = PledgePaymentPlanOptionData(type: .pledgeInFull, selectedType: .pledgeOverTime) + let data = PledgePaymentPlanOptionData( + type: .pledgeInFull, + selectedType: .pledgeOverTime, + paymentIncrements: mockPaymentIncrements(), + project: Project.template + ) self.vm.inputs.configureWith(data: data) self.titleText.assertValue(self.pledgeInFullTitle) self.subtitleText.assertValue("") self.subtitleLabelHidden.assertValue(true) + self.termsOfUseButtonHidden.assertValue(true) + self.paymentIncrementsHidden.assertValue(true) self.selectionIndicatorImageName.assertValue(self.unselectedImageName) + self.paymentIncrements.assertValues([]) } func testPaymentPlanOption_PledgeOverTime_Selected() { - let data = PledgePaymentPlanOptionData(type: .pledgeOverTime, selectedType: .pledgeOverTime) + let increments = mockPaymentIncrements() + let project = Project.template + let incrementsFormatted = paymentIncrementsFormatted(from: increments, project: project) + let data = PledgePaymentPlanOptionData( + type: .pledgeOverTime, + selectedType: .pledgeOverTime, + paymentIncrements: increments, + project: project + ) + self.vm.inputs.configureWith(data: data) self.titleText.assertValue(self.pledgeOverTimeTitle) - self.subtitleText.assertValue(self.pledgeOverTimeSubtitle) + self.subtitleText.assertValue(self.pledgeOverTimeFullSubtitle) self.subtitleLabelHidden.assertValue(false) + self.termsOfUseButtonHidden.assertValue(false) + self.paymentIncrementsHidden.assertValue(false) self.selectionIndicatorImageName.assertValue(self.selectedImageName) + self.paymentIncrements.assertValues([incrementsFormatted]) } func testPaymentPlanOption_PledgeOverTime_Unselected() { - let data = PledgePaymentPlanOptionData(type: .pledgeOverTime, selectedType: .pledgeInFull) + let data = PledgePaymentPlanOptionData( + type: .pledgeOverTime, + selectedType: .pledgeInFull, + paymentIncrements: mockPaymentIncrements(), + project: Project.template + ) self.vm.inputs.configureWith(data: data) self.titleText.assertValue(self.pledgeOverTimeTitle) self.subtitleText.assertValue(self.pledgeOverTimeSubtitle) self.subtitleLabelHidden.assertValue(false) - + self.termsOfUseButtonHidden.assertValue(true) + self.paymentIncrementsHidden.assertValue(true) self.selectionIndicatorImageName.assertValue(self.unselectedImageName) + self.paymentIncrements.assertValues([]) } func testPaymentPlanOption_OptionTapped() { - let data = PledgePaymentPlanOptionData(type: .pledgeOverTime, selectedType: .pledgeInFull) + let data = PledgePaymentPlanOptionData( + type: .pledgeOverTime, + selectedType: .pledgeInFull, + paymentIncrements: mockPaymentIncrements(), + project: Project.template + ) self.vm.inputs.configureWith(data: data) self.vm.inputs.optionTapped() self.notifyDelegatePaymentPlanOptionSelected.assertValue(.pledgeOverTime) } } + +private func mockPaymentIncrements() -> [PledgePaymentIncrement] { + let amount = PledgePaymentIncrementAmount(amount: 250.0, currency: "USD") + let scheduledCollection = TimeInterval(1_553_731_200) + return [ + PledgePaymentIncrement(amount: amount, scheduledCollection: scheduledCollection), + PledgePaymentIncrement(amount: amount, scheduledCollection: scheduledCollection) + ] +} + +private func paymentIncrementsFormatted(from increments: [PledgePaymentIncrement], project: Project) + -> [PledgePaymentIncrementFormatted] { + increments.enumerated().map { + PledgePaymentIncrementFormatted(from: $1, index: $0, project: project) + } +} diff --git a/Library/ViewModels/PledgePaymentPlansViewModel.swift b/Library/ViewModels/PledgePaymentPlansViewModel.swift index fbc30e2f5f..91458c365b 100644 --- a/Library/ViewModels/PledgePaymentPlansViewModel.swift +++ b/Library/ViewModels/PledgePaymentPlansViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import KsApi import Prelude import ReactiveSwift @@ -7,19 +8,22 @@ public enum PledgePaymentPlansType: Equatable { case pledgeOverTime } -public struct PledgePaymentPlansAndSelectionData: Equatable { +public struct PledgePaymentPlansAndSelectionData { public var selectedPlan: PledgePaymentPlansType + public var paymentIncrements: [PledgePaymentIncrement] + public var project: Project /* TODO: add the necesary properties for the next states (PLOT Selected and Ineligible) - - [MBL-1815](https://kickstarter.atlassian.net/browse/MBL-1815) - [MBL-1816](https://kickstarter.atlassian.net/browse/MBL-1816) */ - public init() { - self.selectedPlan = .pledgeInFull - } - - public init(selectedPlan: PledgePaymentPlansType) { + public init( + selectedPlan: PledgePaymentPlansType, + increments paymentIncrements: [PledgePaymentIncrement] = [], + project: Project + ) { self.selectedPlan = selectedPlan + self.paymentIncrements = paymentIncrements + self.project = project } } @@ -27,10 +31,12 @@ public protocol PledgePaymentPlansViewModelInputs { func viewDidLoad() func configure(with value: PledgePaymentPlansAndSelectionData) func didSelectPlanType(_ planType: PledgePaymentPlansType) + func didTapTermsOfUse(with helpType: HelpType) } public protocol PledgePaymentPlansViewModelOutputs { var notifyDelegatePaymentPlanSelected: Signal { get } + var notifyDelegateTermsOfUseTapped: Signal { get } var reloadPaymentPlans: Signal { get } } @@ -44,6 +50,7 @@ public final class PledgePaymentPlansViewModel: PledgePaymentPlansViewModelType, PledgePaymentPlansViewModelOutputs { public var reloadPaymentPlans: Signal public var notifyDelegatePaymentPlanSelected: Signal + public var notifyDelegateTermsOfUseTapped: Signal public var inputs: PledgePaymentPlansViewModelInputs { return self } public var outputs: PledgePaymentPlansViewModelOutputs { return self } @@ -68,9 +75,18 @@ public final class PledgePaymentPlansViewModel: PledgePaymentPlansViewModelType, self.reloadPaymentPlans = Signal.merge( planType, selectedPlanType - ).map { PledgePaymentPlansAndSelectionData(selectedPlan: $0) } + ).combineLatest(with: configureWithValue) + .map { selectedPlan, data in + PledgePaymentPlansAndSelectionData( + selectedPlan: selectedPlan, + increments: data.paymentIncrements, + project: data.project + ) + } self.notifyDelegatePaymentPlanSelected = selectedPlanType.signal.skipRepeats() + + self.notifyDelegateTermsOfUseTapped = self.didTermsOfUseTappedProperty.signal.skipNil() } private let configureWithValueProperty = MutableProperty(nil) @@ -82,4 +98,9 @@ public final class PledgePaymentPlansViewModel: PledgePaymentPlansViewModelType, public func didSelectPlanType(_ planType: PledgePaymentPlansType) { self.didSelectPlanTypeProperty.value = planType } + + private let didTermsOfUseTappedProperty = MutableProperty(nil) + public func didTapTermsOfUse(with helpType: HelpType) { + self.didTermsOfUseTappedProperty.value = helpType + } } diff --git a/Library/ViewModels/PledgePaymentPlansViewModelTest.swift b/Library/ViewModels/PledgePaymentPlansViewModelTest.swift index 301b47b54e..7159161754 100644 --- a/Library/ViewModels/PledgePaymentPlansViewModelTest.swift +++ b/Library/ViewModels/PledgePaymentPlansViewModelTest.swift @@ -1,4 +1,5 @@ import Foundation +@testable import KsApi @testable import Library import ReactiveExtensions import ReactiveExtensions_TestHelpers @@ -11,14 +12,11 @@ final class PledgePaymentPlansViewModelTests: TestCase { private var reloadPaymentPlansPlanType = TestObserver() private var notifyDelegatePaymentPlanSelected = TestObserver() + private var notifyDelegateTermsOfUseTapped = TestObserver() - private let pledgeInFullIndexPath = IndexPath( - row: 0, - section: 0 - ) - private let pledgeOverTimeIndexPath = IndexPath( - row: 0, - section: 1 + private let selectionData = PledgePaymentPlansAndSelectionData( + selectedPlan: .pledgeInFull, + project: Project.template ) // MARK: Lifecycle @@ -32,6 +30,8 @@ final class PledgePaymentPlansViewModelTests: TestCase { self.vm.outputs.reloadPaymentPlans .map { $0.selectedPlan } .observe(self.reloadPaymentPlansPlanType.observer) + + self.vm.outputs.notifyDelegateTermsOfUseTapped.observe(self.notifyDelegateTermsOfUseTapped.observer) } // MARK: Test cases @@ -49,9 +49,7 @@ final class PledgePaymentPlansViewModelTests: TestCase { withEnvironment { self.vm.inputs.viewDidLoad() - let data = PledgePaymentPlansAndSelectionData(selectedPlan: .pledgeInFull) - - self.vm.inputs.configure(with: data) + self.vm.inputs.configure(with: self.selectionData) self.reloadPaymentPlansPlanType.assertValues([.pledgeInFull]) self.notifyDelegatePaymentPlanSelected.assertDidNotEmitValue() } @@ -61,8 +59,10 @@ final class PledgePaymentPlansViewModelTests: TestCase { withEnvironment { self.vm.inputs.viewDidLoad() - self.vm.inputs.didSelectPlanType(.pledgeInFull) + self.vm.inputs.configure(with: self.selectionData) self.reloadPaymentPlansPlanType.assertValues([.pledgeInFull]) + self.vm.inputs.didSelectPlanType(.pledgeInFull) + self.reloadPaymentPlansPlanType.assertValues([.pledgeInFull, .pledgeInFull]) self.notifyDelegatePaymentPlanSelected.assertValues([.pledgeInFull]) } } @@ -71,9 +71,20 @@ final class PledgePaymentPlansViewModelTests: TestCase { withEnvironment { self.vm.inputs.viewDidLoad() + self.vm.inputs.configure(with: self.selectionData) + self.reloadPaymentPlansPlanType.assertValues([.pledgeInFull]) self.vm.inputs.didSelectPlanType(.pledgeOverTime) - self.reloadPaymentPlansPlanType.assertValues([.pledgeOverTime]) + self.reloadPaymentPlansPlanType.assertValues([.pledgeInFull, .pledgeOverTime]) self.notifyDelegatePaymentPlanSelected.assertValues([.pledgeOverTime]) } } + + func testPaymenPlans_TermsOfUseTapped() { + self.vm.inputs.viewDidLoad() + + self.vm.inputs.configure(with: self.selectionData) + self.vm.inputs.didTapTermsOfUse(with: .terms) + + self.notifyDelegateTermsOfUseTapped.assertValues([HelpType.terms]) + } }