diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAddressSummary.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAddressSummary.swift index d29f29604c..0108fb8fdb 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAddressSummary.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAddressSummary.swift @@ -9,13 +9,13 @@ struct PPOAddressSummary: View { HStack(alignment: .firstTextBaseline) { // TODO: Localize Text("Shipping address") - .font(Font(PPOCardStyles.title.font)) - .foregroundStyle(Color(PPOCardStyles.title.color)) + .font(Font(PPOStyles.title.font)) + .foregroundStyle(Color(PPOStyles.title.color)) .frame(width: self.leadingColumnWidth, alignment: Constants.textAlignment) Text(self.address) - .font(Font(PPOCardStyles.body.font)) - .foregroundStyle(Color(PPOCardStyles.body.color)) + .font(Font(PPOStyles.body.font)) + .foregroundStyle(Color(PPOStyles.body.color)) .frame(maxWidth: Constants.maxWidth, alignment: Constants.textAlignment) } .frame(maxWidth: Constants.maxWidth) diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAlertFlag.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAlertFlag.swift index c38fe7234d..a90e80050d 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAlertFlag.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOAlertFlag.swift @@ -15,7 +15,7 @@ struct PPOAlertFlag: View { Spacer() .frame(width: Constants.spacerWidth) Text(self.alert.message) - .font(Font(PPOCardStyles.title.font)) + .font(Font(PPOStyles.title.font)) .foregroundStyle(self.foregroundColor) } .padding(Constants.padding) @@ -27,29 +27,29 @@ struct PPOAlertFlag: View { } var image: Image { - switch self.alert.type { + switch self.alert.icon { case .time: - Image(PPOCardStyles.timeImage) + Image(PPOStyles.timeImage) case .alert: - Image(PPOCardStyles.alertImage) + Image(PPOStyles.alertImage) } } var foregroundColor: Color { - switch self.alert.icon { + switch self.alert.type { case .warning: - Color(uiColor: PPOCardStyles.warningColor.foreground) + Color(uiColor: PPOStyles.warningColor.foreground) case .alert: - Color(uiColor: PPOCardStyles.alertColor.foreground) + Color(uiColor: PPOStyles.alertColor.foreground) } } var backgroundColor: Color { - switch self.alert.icon { + switch self.alert.type { case .warning: - Color(uiColor: PPOCardStyles.warningColor.background) + Color(uiColor: PPOStyles.warningColor.background) case .alert: - Color(uiColor: PPOCardStyles.alertColor.background) + Color(uiColor: PPOStyles.alertColor.background) } } @@ -64,10 +64,10 @@ struct PPOAlertFlag: View { #Preview("Stack of flags") { VStack(alignment: .leading, spacing: 8) { - PPOAlertFlag(alert: .init(type: .time, icon: .warning, message: "Address locks in 8 hours")) - PPOAlertFlag(alert: .init(type: .alert, icon: .warning, message: "Survey available")) + PPOAlertFlag(alert: .init(type: .warning, icon: .time, message: "Address locks in 8 hours")) + PPOAlertFlag(alert: .init(type: .warning, icon: .alert, message: "Survey available")) PPOAlertFlag(alert: .init(type: .alert, icon: .alert, message: "Payment failed")) - PPOAlertFlag(alert: .init(type: .time, icon: .alert, message: "Pledge will be dropped in 6 days")) + PPOAlertFlag(alert: .init(type: .alert, icon: .time, message: "Pledge will be dropped in 6 days")) PPOAlertFlag(alert: .init(type: .alert, icon: .alert, message: "Card needs authentication")) } } diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCard.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCard.swift index 62a7ca5912..07ca6e501c 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCard.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCard.swift @@ -5,15 +5,20 @@ import SwiftUI struct PPOProjectCard: View { @StateObject var viewModel: PPOProjectCardViewModel + var parentSize: CGSize + + var onViewBackingDetails: ((PPOProjectCardModel) -> Void)? = nil + var onSendMessage: ((PPOProjectCardModel) -> Void)? = nil + var onPerformAction: ((PPOProjectCardModel, PPOProjectCardModel.Action) -> Void)? = nil var body: some View { VStack(spacing: Constants.spacing) { self.flagList - self.projectDetails(leadingColumnWidth: self.viewModel.parentSize.width * Constants.firstColumnWidth) + self.projectDetails(leadingColumnWidth: self.parentSize.width * Constants.firstColumnWidth) self.divider self.projectCreator self.divider - self.addressDetails(leadingColumnWidth: self.viewModel.parentSize.width * Constants.firstColumnWidth) + self.addressDetails(leadingColumnWidth: self.parentSize.width * Constants.firstColumnWidth) self.actionButtons } .padding(.vertical) @@ -32,8 +37,16 @@ struct PPOProjectCard: View { content: { self.badge.opacity(self.viewModel.card.isUnread ? 1 : 0) } ) - // insets - .padding(.horizontal, Constants.outerPadding) + // Handle actions + .onReceive(self.viewModel.viewBackingDetailsTapped) { + self.onViewBackingDetails?(self.viewModel.card) + } + .onReceive(self.viewModel.sendMessageTapped) { + self.onSendMessage?(self.viewModel.card) + } + .onReceive(self.viewModel.actionPerformed) { action in + self.onPerformAction?(self.viewModel.card, action) + } } @ViewBuilder @@ -44,7 +57,7 @@ struct PPOProjectCard: View { @ViewBuilder private var badge: some View { Circle() - .fill(Color(uiColor: PPOCardStyles.badgeColor)) + .fill(Color(uiColor: PPOStyles.badgeColor)) .frame(width: Constants.badgeSize, height: Constants.badgeSize) .offset(x: Constants.badgeSize / 2, y: -(Constants.badgeSize / 2)) } @@ -67,7 +80,7 @@ struct PPOProjectCard: View { @ViewBuilder private func projectDetails(leadingColumnWidth: CGFloat) -> some View { PPOProjectDetails( - imageUrl: self.viewModel.card.imageURL, + image: self.viewModel.card.image, title: self.viewModel.card.title, pledge: self.viewModel.card.pledge, leadingColumnWidth: leadingColumnWidth @@ -77,8 +90,13 @@ struct PPOProjectCard: View { @ViewBuilder private var projectCreator: some View { - PPOProjectCreator(creatorName: self.viewModel.card.creatorName) - .padding([.horizontal]) + PPOProjectCreator( + creatorName: self.viewModel.card.creatorName, + onSendMessage: { [weak viewModel] () in + viewModel?.sendCreatorMessage() + } + ) + .padding([.horizontal]) } @ViewBuilder @@ -146,7 +164,10 @@ struct PPOProjectCard: View { ScrollView(.vertical) { VStack(spacing: 16) { ForEach(PPOProjectCardModel.previewTemplates) { template in - PPOProjectCard(viewModel: PPOProjectCardViewModel(card: template, parentSize: geometry.size)) + PPOProjectCard( + viewModel: PPOProjectCardViewModel(card: template), + parentSize: geometry.size + ) } } } diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardModel.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardModel.swift index 22864cbbae..aaacf11703 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardModel.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardModel.swift @@ -1,11 +1,12 @@ import Foundation +import Kingfisher import KsApi import Library -public struct PPOProjectCardModel: Identifiable, Equatable { +public struct PPOProjectCardModel: Identifiable, Equatable, Hashable { public let isUnread: Bool public let alerts: [Alert] - public let imageURL: URL + public let image: Kingfisher.Source public let title: String public let pledge: GraphAPI.MoneyFragment public let creatorName: String @@ -15,6 +16,19 @@ public struct PPOProjectCardModel: Identifiable, Equatable { public let backingDetailsUrl: String public let projectAnalytics: GraphAPI.ProjectAnalyticsFragment + public func hash(into hasher: inout Hasher) { + hasher.combine(self.isUnread) + hasher.combine(self.alerts) + hasher.combine(self.image) + hasher.combine(self.title) + hasher.combine(self.pledge) + hasher.combine(self.creatorName) + hasher.combine(self.address) + hasher.combine(self.actions.0) + hasher.combine(self.actions.1) + hasher.combine(self.tierType) + } + // MARK: - Identifiable public let id = UUID() @@ -26,7 +40,7 @@ public struct PPOProjectCardModel: Identifiable, Equatable { public static func == (lhs: PPOProjectCardModel, rhs: PPOProjectCardModel) -> Bool { lhs.isUnread == rhs.isUnread && lhs.alerts == rhs.alerts && - lhs.imageURL == rhs.imageURL && + lhs.image == rhs.image && lhs.title == rhs.title && lhs.pledge == rhs.pledge && lhs.creatorName == rhs.creatorName && @@ -41,7 +55,7 @@ public struct PPOProjectCardModel: Identifiable, Equatable { case confirmAddress } - public enum Action: Identifiable, Equatable { + public enum Action: Identifiable, Equatable, Hashable { case confirmAddress case editAddress case completeSurvey @@ -98,7 +112,7 @@ public struct PPOProjectCardModel: Identifiable, Equatable { } } - public struct Alert: Identifiable, Equatable { + public struct Alert: Identifiable, Equatable, Hashable { public let type: AlertType public let icon: AlertIcon public let message: String @@ -113,7 +127,7 @@ public struct PPOProjectCardModel: Identifiable, Equatable { "\(self.type)-\(self.icon)-\(self.message)" } - public enum AlertType: Identifiable, Equatable { + public enum AlertIcon: Identifiable, Equatable { case time case alert @@ -127,7 +141,7 @@ public struct PPOProjectCardModel: Identifiable, Equatable { } } - public enum AlertIcon: Identifiable, Equatable { + public enum AlertType: Identifiable, Equatable { case warning case alert @@ -145,7 +159,7 @@ public struct PPOProjectCardModel: Identifiable, Equatable { extension PPOProjectCardModel.Alert { init?(flag: GraphAPI.PpoCardFragment.Flag) { - let alertType: PPOProjectCardModel.Alert.AlertType? = switch flag.type { + let alertIcon: PPOProjectCardModel.Alert.AlertIcon? = switch flag.icon { case "alert": .alert case "time": @@ -154,7 +168,7 @@ extension PPOProjectCardModel.Alert { nil } - let alertIcon: PPOProjectCardModel.Alert.AlertIcon? = switch flag.icon { + let alertType: PPOProjectCardModel.Alert.AlertType? = switch flag.type { case "alert": .alert case "warning": @@ -180,6 +194,14 @@ extension GraphAPI.MoneyFragment: Equatable { } } +extension GraphAPI.MoneyFragment: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(self.amount) + hasher.combine(self.currency) + hasher.combine(self.symbol) + } +} + extension PPOProjectCardModel { #if targetEnvironment(simulator) public static let previewTemplates: [PPOProjectCardModel] = [ @@ -194,9 +216,9 @@ extension PPOProjectCardModel { internal static let confirmAddressTemplate = PPOProjectCardModel( isUnread: true, alerts: [ - .init(type: .time, icon: .warning, message: "Address locks in 8 hours") + .init(type: .warning, icon: .time, message: "Address locks in 8 hours") ], - imageURL: URL(string: "http://localhost/")!, + image: .network(URL(string: "https:///")!), title: "Sugardew Island - Your cozy farm shop let’s pretend this is a way way way longer title", pledge: .init(amount: "50.00", currency: .usd, symbol: "$"), creatorName: "rokaplay truncate if longer than", @@ -215,10 +237,10 @@ extension PPOProjectCardModel { internal static let addressLockTemplate = PPOProjectCardModel( isUnread: true, alerts: [ - .init(type: .alert, icon: .warning, message: "Survey available"), - .init(type: .time, icon: .warning, message: "Address locks in 48 hours") + .init(type: .warning, icon: .alert, message: "Survey available"), + .init(type: .warning, icon: .time, message: "Address locks in 48 hours") ], - imageURL: URL(string: "http://localhost/")!, + image: .network(URL(string: "https:///")!), title: "Sugardew Island - Your cozy farm shop let’s pretend this is a way way way longer title", pledge: .init(amount: "50.00", currency: .usd, symbol: "$"), creatorName: "rokaplay truncate if longer than", @@ -234,12 +256,12 @@ extension PPOProjectCardModel { alerts: [ .init(type: .alert, icon: .alert, message: "Payment failed"), .init( - type: .time, - icon: .alert, + type: .alert, + icon: .time, message: "Pledge will be dropped in 6 days" ) ], - imageURL: URL(string: "http://localhost/")!, + image: .network(URL(string: "https:///")!), title: "Sugardew Island - Your cozy farm shop let’s pretend this is a way way way longer title", pledge: .init(amount: "50.00", currency: .usd, symbol: "$"), creatorName: "rokaplay truncate if longer than", @@ -255,12 +277,12 @@ extension PPOProjectCardModel { alerts: [ .init(type: .alert, icon: .alert, message: "Card needs authentication"), .init( - type: .time, - icon: .alert, + type: .alert, + icon: .time, message: "Pledge will be dropped in 6 days" ) ], - imageURL: URL(string: "http://localhost/")!, + image: .network(URL(string: "https:///")!), title: "Sugardew Island - Your cozy farm shop let’s pretend this is a way way way longer title", pledge: .init(amount: "50.00", currency: .usd, symbol: "$"), creatorName: "rokaplay truncate if longer than", @@ -274,9 +296,9 @@ extension PPOProjectCardModel { internal static let completeSurveyTemplate = PPOProjectCardModel( isUnread: true, alerts: [ - .init(type: .alert, icon: .warning, message: "Survey available") + .init(type: .warning, icon: .alert, message: "Survey available") ], - imageURL: URL(string: "http://localhost/")!, + image: .network(URL(string: "https:///")!), title: "Sugardew Island - Your cozy farm shop let’s pretend this is a way way way longer title", pledge: .init(amount: "50.00", currency: .usd, symbol: "$"), creatorName: "rokaplay truncate if longer than", @@ -330,8 +352,9 @@ extension PPOProjectCardModel { let backing = card.backing?.fragments.ppoBackingFragment let ppoProject = backing?.project?.fragments.ppoProjectFragment - let imageURL = ppoProject?.image?.url + let image = ppoProject?.image?.url .flatMap { URL(string: $0) } + .map { Kingfisher.Source.network($0) } let title = ppoProject?.name let pledge = backing?.amount.fragments.moneyFragment @@ -373,12 +396,11 @@ extension PPOProjectCardModel { let projectAnalyticsFragment = backing?.project?.fragments.projectAnalyticsFragment - if let imageURL, let title, let pledge, let creatorName, let projectAnalyticsFragment, - let backingDetailsUrl { + if let image, let title, let pledge, let creatorName, let projectAnalyticsFragment, let backingDetailsUrl { self.init( isUnread: true, alerts: alerts, - imageURL: imageURL, + image: image, title: title, pledge: pledge, creatorName: creatorName, diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardTests.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardTests.swift index 6f3c121a58..1f9fb2d9be 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardTests.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardTests.swift @@ -1,4 +1,5 @@ @testable import Kickstarter_Framework +import Kingfisher @testable import KsApi import SnapshotTesting import SwiftUI @@ -6,69 +7,75 @@ import XCTest final class PPOProjectCardTests: TestCase { let size = CGSize(width: 375, height: 700) - func testAddressLocks() { + + @MainActor + func testAddressLocks() async { let card = VStack { PPOProjectCard(viewModel: PPOProjectCardViewModel( - card: .confirmAddressTemplate, - parentSize: self.size - )) - .frame(width: self.size.width) - .frame(maxHeight: .infinity) - .padding() + card: .confirmAddressTemplate + ), parentSize: self.size) + .frame(width: self.size.width) + .frame(maxHeight: .infinity) + .padding() }.frame(height: 500) + try? await Task.sleep(nanoseconds: 10_000_000) assertSnapshot(matching: card, as: .image, named: "addressLocks") } - func testSurveyAvailableAddressLocks() { + @MainActor + func testSurveyAvailableAddressLocks() async { let card = VStack { PPOProjectCard(viewModel: PPOProjectCardViewModel( - card: .addressLockTemplate, - parentSize: self.size - )) - .frame(width: self.size.width) - .frame(maxHeight: .infinity) - .padding() + card: .addressLockTemplate + ), parentSize: self.size) + .frame(width: self.size.width) + .frame(maxHeight: .infinity) + .padding() }.frame(height: 500) + try? await Task.sleep(nanoseconds: 10_000_000) assertSnapshot(matching: card, as: .image, named: "surveyAvailableAddressLocks") } - func testPaymentFailedPledgeDropped() { + @MainActor + func testPaymentFailedPledgeDropped() async { let card = VStack { PPOProjectCard(viewModel: PPOProjectCardViewModel( - card: .fixPaymentTemplate, - parentSize: self.size - )) - .frame(width: self.size.width) - .frame(maxHeight: .infinity) - .padding() + card: .fixPaymentTemplate + ), parentSize: self.size) + .frame(width: self.size.width) + .frame(maxHeight: .infinity) + .padding() }.frame(height: 500) + try? await Task.sleep(nanoseconds: 10_000_000) assertSnapshot(matching: card, as: .image, named: "paymentFailedPledgeDropped") } - func testCardAuthPledgeDropped() { + @MainActor + func testCardAuthPledgeDropped() async { let card = VStack { PPOProjectCard(viewModel: PPOProjectCardViewModel( - card: .authenticateCardTemplate, - parentSize: self.size - )) - .frame(width: self.size.width) - .frame(maxHeight: .infinity) - .padding() + card: .authenticateCardTemplate + ), parentSize: self.size) + .frame(width: self.size.width) + .frame(maxHeight: .infinity) + .padding() }.frame(height: 500) + try? await Task.sleep(nanoseconds: 10_000_000) assertSnapshot(matching: card, as: .image, named: "cardAuthPledgeDropped") } - func testSurveyAvailable() { + @MainActor + func testSurveyAvailable() async { let card = VStack { PPOProjectCard(viewModel: PPOProjectCardViewModel( - card: .completeSurveyTemplate, - parentSize: self.size - )) - .frame(width: self.size.width) - .frame(maxHeight: .infinity) - .padding() + card: .completeSurveyTemplate + ), parentSize: self.size) + .frame(width: self.size.width) + .frame(maxHeight: .infinity) + .padding() }.frame(height: 500) + try? await Task.sleep(nanoseconds: 10_000_000) assertSnapshot(matching: card, as: .image, named: "surveyAvailable") } } diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModel.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModel.swift index b5753a957e..c48e15254b 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModel.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModel.swift @@ -3,16 +3,17 @@ import Foundation import KsApi protocol PPOProjectCardViewModelInputs { + func viewBackingDetails() func sendCreatorMessage() func performAction(action: PPOProjectCardModel.Action) } protocol PPOProjectCardViewModelOutputs { + var viewBackingDetailsTapped: AnyPublisher { get } var sendMessageTapped: AnyPublisher { get } var actionPerformed: AnyPublisher { get } var card: PPOProjectCardModel { get } - var parentSize: CGSize { get } } extension PPOProjectCardViewModelOutputs { @@ -27,37 +28,44 @@ extension PPOProjectCardViewModelOutputs { } } -typealias PPOProjectCardViewModelType = Equatable & Identifiable & ObservableObject & +typealias PPOProjectCardViewModelType = Equatable & Hashable & Identifiable & ObservableObject & PPOProjectCardViewModelInputs & PPOProjectCardViewModelOutputs final class PPOProjectCardViewModel: PPOProjectCardViewModelType { @Published private(set) var card: PPOProjectCardModel - @Published private(set) var parentSize: CGSize + + func hash(into hasher: inout Hasher) { + hasher.combine(self.card) + } init( - card: PPOProjectCardModel, - parentSize: CGSize + card: PPOProjectCardModel ) { self.card = card - self.parentSize = parentSize } // MARK: - Inputs func sendCreatorMessage() { - self.sendCreatorMessageSubject.send(()) + self.sendCreatorMessageSubject.send() } func performAction(action: Action) { self.actionPerformedSubject.send(action) } + func viewBackingDetails() { + self.viewBackingDetailsSubject.send() + } + // MARK: - Outputs + var viewBackingDetailsTapped: AnyPublisher<(), Never> { self.viewBackingDetailsSubject.eraseToAnyPublisher() } var sendMessageTapped: AnyPublisher<(), Never> { self.sendCreatorMessageSubject.eraseToAnyPublisher() } var actionPerformed: AnyPublisher { self.actionPerformedSubject.eraseToAnyPublisher() } + private let viewBackingDetailsSubject = PassthroughSubject() private let sendCreatorMessageSubject = PassthroughSubject() private let actionPerformedSubject = PassthroughSubject() @@ -69,6 +77,6 @@ final class PPOProjectCardViewModel: PPOProjectCardViewModelType { // MARK: - Equatable static func == (lhs: PPOProjectCardViewModel, rhs: PPOProjectCardViewModel) -> Bool { - lhs.card == rhs.card && lhs.parentSize == rhs.parentSize + lhs.card == rhs.card } } diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModelTests.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModelTests.swift index 49f8459f8e..f15b69156f 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModelTests.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCardViewModelTests.swift @@ -7,8 +7,7 @@ final class PPOProjectCardViewModelTests: XCTestCase { func testPerformAction() throws { var cancellables: [AnyCancellable] = [] let viewModel = PPOProjectCardViewModel( - card: PPOProjectCardModel.authenticateCardTemplate, - parentSize: CGSize(width: 375, height: 700) + card: PPOProjectCardModel.authenticateCardTemplate ) let expectation = expectation(description: "Waiting for action to be performed") @@ -29,8 +28,7 @@ final class PPOProjectCardViewModelTests: XCTestCase { func testSendMessage() throws { var cancellables: [AnyCancellable] = [] let viewModel = PPOProjectCardViewModel( - card: PPOProjectCardModel.authenticateCardTemplate, - parentSize: CGSize(width: 375, height: 700) + card: PPOProjectCardModel.authenticateCardTemplate ) let expectation = expectation(description: "Waiting for message to be sent") diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCreator.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCreator.swift index de4c00267d..a00c2f5866 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCreator.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectCreator.swift @@ -4,13 +4,15 @@ import SwiftUI struct PPOProjectCreator: View { let creatorName: String + var onSendMessage: (() -> Void)? = nil var body: some View { HStack(alignment: .firstTextBaseline) { // TODO: Localize Text("Created by **\(self.creatorName)**") - .font(Font(PPOCardStyles.subtitle.font)) - .foregroundStyle(Color(PPOCardStyles.subtitle.color)) + .font(Font(PPOStyles.subtitle.font)) + .background(Color(PPOStyles.background)) + .foregroundStyle(Color(PPOStyles.subtitle.color)) .frame( maxWidth: Constants.labelMaxWidth, alignment: Constants.labelAlignment @@ -18,12 +20,13 @@ struct PPOProjectCreator: View { .lineLimit(Constants.textLineLimit) Button(action: { - // TODO: Action + self.onSendMessage?() }, label: { // TODO: Localize Text("Send a message") }) - .font(Font(PPOCardStyles.subtitle.font)) + .font(Font(PPOStyles.subtitle.font)) + .background(Color(PPOStyles.background)) .foregroundStyle(Color(Constants.sendMessageColor)) .frame(alignment: Constants.buttonAlignment) .lineLimit(Constants.textLineLimit) @@ -36,6 +39,7 @@ struct PPOProjectCreator: View { .scaledToFit() .frame(width: Constants.chevronSize, height: Constants.chevronSize) .offset(Constants.chevronOffset) + .background(Color(PPOStyles.background)) .foregroundStyle(Color(Constants.sendMessageColor)) } .frame(maxWidth: .infinity) diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectDetails.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectDetails.swift index 3f67ef8b1e..6fa6aa8127 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectDetails.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/PPOProjectDetails.swift @@ -5,14 +5,17 @@ import Library import SwiftUI struct PPOProjectDetails: View { - let imageUrl: URL? + let image: Source? let title: String? let pledge: GraphAPI.MoneyFragment let leadingColumnWidth: CGFloat var body: some View { HStack { - KFImage(self.imageUrl) + KFImage(source: self.image) + .placeholder { + Color(.ksr_support_200) + } .resizable() .clipShape(Constants.imageShape) .aspectRatio( @@ -25,8 +28,9 @@ struct PPOProjectDetails: View { VStack { if let title { Text(title) - .font(Font(PPOCardStyles.title.font)) - .foregroundStyle(Color(PPOCardStyles.title.color)) + .font(Font(PPOStyles.title.font)) + .background(Color(PPOStyles.background)) + .foregroundStyle(Color(PPOStyles.title.color)) .frame( maxWidth: Constants.textMaxWidth, alignment: Constants.textAlignment @@ -36,8 +40,9 @@ struct PPOProjectDetails: View { if let symbol = pledge.symbol, let amount = pledge.amount { // TODO: Localize Text("\(symbol)\(amount) pledged") - .font(Font(PPOCardStyles.subtitle.font)) - .foregroundStyle(Color(PPOCardStyles.subtitle.color)) + .font(Font(PPOStyles.subtitle.font)) + .background(Color(PPOStyles.background)) + .foregroundStyle(Color(PPOStyles.subtitle.color)) .frame( maxWidth: Constants.textMaxWidth, alignment: Constants.textAlignment @@ -69,7 +74,7 @@ struct PPOProjectDetails: View { VStack { GeometryReader(content: { geometry in PPOProjectDetails( - imageUrl: URL(string: "http:///")!, + image: .network(URL(string: "http:///")!), title: "Sugardew Island - Your cozy farm shop let’s pretend this is a way way way longer title", pledge: GraphAPI.MoneyFragment(amount: "50.00", currency: .usd, symbol: "$"), leadingColumnWidth: geometry.size.width / 4 @@ -77,7 +82,7 @@ struct PPOProjectDetails: View { }) GeometryReader(content: { geometry in PPOProjectDetails( - imageUrl: URL(string: "http:///")!, + image: .network(URL(string: "http:///")!), title: "One line", pledge: GraphAPI.MoneyFragment(amount: "50.00", currency: .usd, symbol: "$"), leadingColumnWidth: geometry.size.width / 4 diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testAddressLocks.addressLocks.png b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testAddressLocks.addressLocks.png index 6ed64cca2b..80c14fe2c9 100644 Binary files a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testAddressLocks.addressLocks.png and b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testAddressLocks.addressLocks.png differ diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testCardAuthPledgeDropped.cardAuthPledgeDropped.png b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testCardAuthPledgeDropped.cardAuthPledgeDropped.png index f73ca272c2..972748d5f0 100644 Binary files a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testCardAuthPledgeDropped.cardAuthPledgeDropped.png and b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testCardAuthPledgeDropped.cardAuthPledgeDropped.png differ diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testPaymentFailedPledgeDropped.paymentFailedPledgeDropped.png b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testPaymentFailedPledgeDropped.paymentFailedPledgeDropped.png index 93e30f4991..716f5f3190 100644 Binary files a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testPaymentFailedPledgeDropped.paymentFailedPledgeDropped.png and b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testPaymentFailedPledgeDropped.paymentFailedPledgeDropped.png differ diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailable.surveyAvailable.png b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailable.surveyAvailable.png index bcfbafe94f..d4207d95ba 100644 Binary files a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailable.surveyAvailable.png and b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailable.surveyAvailable.png differ diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailableAddressLocks.surveyAvailableAddressLocks.png b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailableAddressLocks.surveyAvailableAddressLocks.png index 734e3b9cb8..c2245df954 100644 Binary files a/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailableAddressLocks.surveyAvailableAddressLocks.png and b/Kickstarter-iOS/Features/PledgedProjectsOverview/CardView/__Snapshots__/PPOProjectCardTests/testSurveyAvailableAddressLocks.surveyAvailableAddressLocks.png differ diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOContainerViewController.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOContainerViewController.swift index b023ebfd8a..1bdc3ceadb 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOContainerViewController.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOContainerViewController.swift @@ -21,10 +21,10 @@ public class PPOContainerViewController: PagedContainerViewController some View { + switch self.viewModel.results { + case .unloaded, .loading(.unloaded), + .loading(.empty), + .loading(.error), + .loading(previous: .loading(_)): + self.loadingView + case .empty: + self.emptyView + case .error: + self.errorView + case + let .someLoaded(values, _, _, _), + let .loading(previous: .someLoaded(values, _, _, _)), + let .allLoaded(values, _), + let .loading(previous: .allLoaded(values, _)): + self.listView(values: values, parentSize: parentSize) + } + } + + @ViewBuilder func listViewHeader(numberOfValues: Int) -> some View { + Text(Strings.Alerts_count(count: numberOfValues.formatted())) + .font(Font(PPOStyles.header.font)) + .background(Color(PPOStyles.header.background)) + .foregroundStyle(Color(PPOStyles.header.foreground)) + .padding(PPOStyles.header.padding) + } + + @ViewBuilder func listView(values: [PPOProjectCardViewModel], parentSize: CGSize) -> some View { + PaginatingList( + data: values, + canLoadMore: false, + selectedItem: nil, + header: { self.listViewHeader(numberOfValues: values.count) } + ) { card in + PPOProjectCard( + viewModel: card, + parentSize: parentSize, + onViewBackingDetails: { card in + self.viewModel.viewBackingDetails(from: card) + }, + onSendMessage: { card in + self.viewModel.contactCreator(from: card) + }, + onPerformAction: { card, action in + switch action { + case .authenticateCard: + self.viewModel.fix3DSChallenge(from: card) + case .completeSurvey: + self.viewModel.openSurvey(from: card) + case .confirmAddress: + self.viewModel.confirmAddress(from: card) + case .editAddress: + self.viewModel.editAddress(from: card) + case .fixPayment: + self.viewModel.fixPaymentMethod(from: card) + } } + ) + .listRowBackground(EmptyView()) + .listRowSeparator(PPOStyles.list.separator) + .listRowInsets(PPOStyles.list.rowInsets) + } onRefresh: { + await self.viewModel.refresh() + } onLoadMore: { + await self.viewModel.loadMore() + } + } + + @ViewBuilder var loadingView: some View { + VStack { + Spacer() + ProgressView() + .controlSize(PPOStyles.loaderControlSize) + .padding() + Spacer() + } + } + + @ViewBuilder var emptyView: some View { + PPOEmptyStateView { + self.onNavigate?(.backedProjects) + } + } + + @ViewBuilder var errorView: some View { + VStack { + Spacer() + if let image = image(named: "icon--refresh-small") { + Image(uiImage: image) + // TODO: Localize + .accessibilityLabel("Refresh") + .accessibilityHint("Refreshes your project alerts.") + .accessibilityAddTraits(.isButton) + .accessibilityRemoveTraits(.isImage) + .onTapGesture { [weak viewModel] () in + Task { + await viewModel?.refresh() + } + } } - .frame(maxWidth: .infinity, alignment: .center) - .overlay(alignment: .bottom) { - MessageBannerView(viewModel: self.$viewModel.bannerViewModel) - .frame( - minWidth: reader.size.width, - idealWidth: reader.size.width, - alignment: .bottom - ) - .animation(.easeInOut, value: self.viewModel.bannerViewModel != nil) - .accessibilityFocused(self.$isBannerFocused) - } + Text(Strings.general_error_something_wrong()) + .font(Font(PPOStyles.error.font)) + .foregroundStyle(Color(PPOStyles.error.foreground)) + .background(Color(PPOStyles.error.background)) + Spacer() + } + } - .onChange(of: self.viewModel.bannerViewModel, perform: { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.isBannerFocused = self.viewModel.bannerViewModel != nil + @ViewBuilder var body: some View { + GeometryReader { reader in + self.contentView(parentSize: reader.size) + .frame(maxWidth: .infinity, alignment: .center) + .overlay(alignment: .bottom) { + MessageBannerView(viewModel: self.$viewModel.bannerViewModel) + .frame( + minWidth: reader.size.width, + idealWidth: reader.size.width, + alignment: .bottom + ) + .animation(.easeInOut, value: self.viewModel.bannerViewModel != nil) + .accessibilityFocused(self.$isBannerFocused) } - }) - .onAppear(perform: { self.viewModel.viewDidAppear() }) - .onChange(of: self.viewModel.results.total, perform: { value in - self.onCountChange?(value) - }) - .onReceive(self.viewModel.navigationEvents, perform: { event in - self.onNavigate?(event) - }) + .onChange(of: self.viewModel.bannerViewModel, perform: { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.isBannerFocused = self.viewModel.bannerViewModel != nil + } + }) + .onAppear(perform: { self.viewModel.viewDidAppear() }) + .onChange(of: self.viewModel.results.total, perform: { value in + self.onCountChange?(value) + }) + .onReceive(self.viewModel.navigationEvents, perform: { event in + self.onNavigate?(event) + }) } } } diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModel.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModel.swift index f073f5c3e3..0174f5318a 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModel.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModel.swift @@ -13,8 +13,8 @@ typealias PPOViewModelPaginator = Paginator< protocol PPOViewModelInputs { func viewDidAppear() - func loadMore() - func pullToRefresh() + func refresh() async + func loadMore() async func openBackedProjects() func fixPaymentMethod(from: PPOProjectCardModel) @@ -45,12 +45,11 @@ enum PPONavigationEvent: Equatable { final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutputs { init() { let paginator: PPOViewModelPaginator = Paginator( - valuesFromEnvelope: { data in + valuesFromEnvelope: { data -> [PPOProjectCardViewModel] in data.pledgeProjectsOverview?.pledges?.edges? .compactMap { edge in edge?.node } .compactMap { node in PPOProjectCardModel(node: node) } - .compactMap { PPOProjectCardViewModel(card: $0, parentSize: .zero) } - ?? [] + .compactMap { model in PPOProjectCardViewModel(card: model) } ?? [] }, cursorFromEnvelope: { data in data.pledgeProjectsOverview?.pledges?.pageInfo.endCursor }, totalFromEnvelope: { data in data.pledgeProjectsOverview?.pledges?.totalCount }, @@ -197,12 +196,14 @@ final class PPOViewModel: ObservableObject, PPOViewModelInputs, PPOViewModelOutp self.viewDidAppearSubject.send(()) } - func loadMore() { + func loadMore() async { self.loadMoreSubject.send(()) + _ = await self.paginator.nextResult() } - func pullToRefresh() { + func refresh() async { self.pullToRefreshSubject.send(()) + _ = await self.paginator.nextResult() } // TODO: Add any additional properties for routing (MBL-1451) diff --git a/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModelTests.swift b/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModelTests.swift index dbe24e9b90..d30ae1e374 100644 --- a/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModelTests.swift +++ b/Kickstarter-iOS/Features/PledgedProjectsOverview/PPOViewModelTests.swift @@ -83,16 +83,19 @@ class PPOViewModelTests: XCTestCase { XCTAssertEqual(data.count, 3) } - func testPullToRefresh_Once() throws { - let expectation = XCTestExpectation(description: "Pull to refresh") - expectation.expectedFulfillmentCount = 5 + func testPullToRefresh_Once() async throws { + let initialLoadExpectation = XCTestExpectation(description: "Initial load") + initialLoadExpectation.expectedFulfillmentCount = 3 + let fullyLoadedExpectation = XCTestExpectation(description: "Pull to refresh") + fullyLoadedExpectation.expectedFulfillmentCount = 5 var values: [PPOViewModelPaginator.Results] = [] self.viewModel.$results .sink { value in values.append(value) - expectation.fulfill() + initialLoadExpectation.fulfill() + fullyLoadedExpectation.fulfill() } .store(in: &self.cancellables) @@ -102,13 +105,15 @@ class PPOViewModelTests: XCTestCase { self.viewModel.viewDidAppear() // Initial load } - withEnvironment(apiService: MockService( + await fulfillment(of: [initialLoadExpectation], timeout: 0.1) + + await withEnvironment(apiService: MockService( fetchPledgedProjectsResult: Result.success(try self.pledgedProjectsData(cursors: 1...2)) - )) { - self.viewModel.pullToRefresh() // Refresh + )) { () async in + await self.viewModel.refresh() // Refresh } - wait(for: [expectation], timeout: 0.1) + await fulfillment(of: [fullyLoadedExpectation], timeout: 0.1) XCTAssertEqual(values.count, 5) @@ -130,16 +135,19 @@ class PPOViewModelTests: XCTestCase { XCTAssertEqual(secondData.count, 2) } - func testPullToRefresh_Twice() throws { - let expectation = XCTestExpectation(description: "Pull to refresh twice") - expectation.expectedFulfillmentCount = 7 + func testPullToRefresh_Twice() async throws { + let initialLoadExpectation = XCTestExpectation(description: "Initial load") + initialLoadExpectation.expectedFulfillmentCount = 3 + let fullyLoadedExpectation = XCTestExpectation(description: "Pull to refresh twice") + fullyLoadedExpectation.expectedFulfillmentCount = 7 var values: [PPOViewModelPaginator.Results] = [] self.viewModel.$results .sink { value in values.append(value) - expectation.fulfill() + initialLoadExpectation.fulfill() + fullyLoadedExpectation.fulfill() } .store(in: &self.cancellables) @@ -149,19 +157,21 @@ class PPOViewModelTests: XCTestCase { self.viewModel.viewDidAppear() // Initial load } - withEnvironment(apiService: MockService( + await fulfillment(of: [initialLoadExpectation], timeout: 0.1) + + await withEnvironment(apiService: MockService( fetchPledgedProjectsResult: Result.success(try self.pledgedProjectsData(cursors: 1...2)) - )) { - self.viewModel.pullToRefresh() // Refresh + )) { () async in + await self.viewModel.refresh() // Refresh } - withEnvironment(apiService: MockService( + await withEnvironment(apiService: MockService( fetchPledgedProjectsResult: Result.success(try self.pledgedProjectsData(cursors: 1...16)) - )) { - self.viewModel.pullToRefresh() // Refresh a second time + )) { () async in + await self.viewModel.refresh() // Refresh a second time } - wait(for: [expectation], timeout: 0.1) + await fulfillment(of: [fullyLoadedExpectation], timeout: 0.1) XCTAssertEqual(values.count, 7) @@ -191,16 +201,19 @@ class PPOViewModelTests: XCTestCase { XCTAssertEqual(thirdData.count, 16) } - func testLoadMore() throws { - let expectation = XCTestExpectation(description: "Load more") - expectation.expectedFulfillmentCount = 5 + func testLoadMore() async throws { + let initialLoadExpectation = XCTestExpectation(description: "Initial load") + initialLoadExpectation.expectedFulfillmentCount = 3 + let fullyLoadedExpectation = XCTestExpectation(description: "Load more") + fullyLoadedExpectation.expectedFulfillmentCount = 5 var values: [PPOViewModelPaginator.Results] = [] self.viewModel.$results .sink { value in values.append(value) - expectation.fulfill() + initialLoadExpectation.fulfill() + fullyLoadedExpectation.fulfill() } .store(in: &self.cancellables) @@ -212,13 +225,16 @@ class PPOViewModelTests: XCTestCase { )) { self.viewModel.viewDidAppear() // Initial load } - withEnvironment(apiService: MockService( + + await fulfillment(of: [initialLoadExpectation], timeout: 0.1) + + await withEnvironment(apiService: MockService( fetchPledgedProjectsResult: Result.success(try self.pledgedProjectsData(cursors: 5...7)) - )) { - self.viewModel.loadMore() // Load next page + )) { () async in + await self.viewModel.loadMore() // Load next page } - wait(for: [expectation], timeout: 0.1) + await fulfillment(of: [fullyLoadedExpectation], timeout: 0.1) XCTAssertEqual(values.count, 5) diff --git a/Kickstarter.xcodeproj/project.pbxproj b/Kickstarter.xcodeproj/project.pbxproj index fce949ed32..0669d896fc 100644 --- a/Kickstarter.xcodeproj/project.pbxproj +++ b/Kickstarter.xcodeproj/project.pbxproj @@ -1148,7 +1148,7 @@ AA6E9E8A2C63EE78001543FC /* PPOAddressSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6E9E892C63EE78001543FC /* PPOAddressSummary.swift */; }; AADDA7142CB6043D00E7112E /* ProjectAnalyticsProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADDA7132CB6043D00E7112E /* ProjectAnalyticsProperties.swift */; }; AADDA7162CB60B8B00E7112E /* ProjectAnalyticsFragment.graphql in Resources */ = {isa = PBXBuildFile; fileRef = AADDA7152CB60B8B00E7112E /* ProjectAnalyticsFragment.graphql */; }; - AAE7C9A22C75142A00800E03 /* PPOCardStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE7C9A12C75142A00800E03 /* PPOCardStyles.swift */; }; + AAE7C9A22C75142A00800E03 /* PPOStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE7C9A12C75142A00800E03 /* PPOStyles.swift */; }; AAE7C9A42C7527E000800E03 /* PagedTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE7C9A32C7527E000800E03 /* PagedTabBar.swift */; }; AAE7C9A72C75948B00800E03 /* PPOProjectCardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE7C9A52C75948B00800E03 /* PPOProjectCardViewModel.swift */; }; AAE7C9A82C75948B00800E03 /* PPOProjectCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE7C9A62C75948B00800E03 /* PPOProjectCard.swift */; }; @@ -2813,7 +2813,7 @@ AAD2BEE82C59B981003D8B95 /* PPOAlertFlag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPOAlertFlag.swift; sourceTree = ""; }; AADDA7132CB6043D00E7112E /* ProjectAnalyticsProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectAnalyticsProperties.swift; sourceTree = ""; }; AADDA7152CB60B8B00E7112E /* ProjectAnalyticsFragment.graphql */ = {isa = PBXFileReference; lastKnownFileType = text; path = ProjectAnalyticsFragment.graphql; sourceTree = ""; }; - AAE7C9A12C75142A00800E03 /* PPOCardStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPOCardStyles.swift; sourceTree = ""; }; + AAE7C9A12C75142A00800E03 /* PPOStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PPOStyles.swift; sourceTree = ""; }; AAE7C9A32C7527E000800E03 /* PagedTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedTabBar.swift; sourceTree = ""; }; AAE7C9A52C75948B00800E03 /* PPOProjectCardViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PPOProjectCardViewModel.swift; sourceTree = ""; }; AAE7C9A62C75948B00800E03 /* PPOProjectCard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PPOProjectCard.swift; sourceTree = ""; }; @@ -6762,7 +6762,6 @@ AAE7C9A52C75948B00800E03 /* PPOProjectCardViewModel.swift */, AAE7C9A92C75949600800E03 /* PPOProjectCardViewModelTests.swift */, AA6E9E892C63EE78001543FC /* PPOAddressSummary.swift */, - AAE7C9A12C75142A00800E03 /* PPOCardStyles.swift */, AAD2BEE82C59B981003D8B95 /* PPOAlertFlag.swift */, AAA592782C5C708000482087 /* PPOProjectDetails.swift */, AA6E9E862C63E7C3001543FC /* PPOProjectCreator.swift */, @@ -7198,6 +7197,7 @@ 392BB1292C3BEB5500A5591B /* PPOEmptyStateView.swift */, AA1023E32CABD2AE007800B5 /* PPOContainerViewModel.swift */, E1BAA0382C1A1C56004F8B06 /* PPOContainerViewController.swift */, + AAE7C9A12C75142A00800E03 /* PPOStyles.swift */, E1BAA0342C1A1907004F8B06 /* PPOView.swift */, E1BAA0362C1A1B13004F8B06 /* PPOViewModel.swift */, E1BAA03D2C1A1CE4004F8B06 /* PPOViewModelTests.swift */, @@ -8481,7 +8481,7 @@ D0971E16221E083100DFEF9B /* SelectCurrencyCell.swift in Sources */, 8A14E09723971C1300387824 /* PledgeShippingLocationShimmerLoadingView.swift in Sources */, 94114D72265329770063E8F6 /* CommentCellHeaderStackView.swift in Sources */, - AAE7C9A22C75142A00800E03 /* PPOCardStyles.swift in Sources */, + AAE7C9A22C75142A00800E03 /* PPOStyles.swift in Sources */, A72C3AB91D00FB1F0075227E /* DiscoverySelectableRowCell.swift in Sources */, 20BCBEB7264DAA5500510EDF /* CommentInputContainerView.swift in Sources */, A75A29281CE0B7DD00D35E5C /* BackingCell.swift in Sources */, diff --git a/KsApi/MockService.swift b/KsApi/MockService.swift index d45d75baa2..eff153caf4 100644 --- a/KsApi/MockService.swift +++ b/KsApi/MockService.swift @@ -1827,7 +1827,10 @@ switch response { case let .success(pledgedProjectsData): - return Just(pledgedProjectsData).setFailureType(to: ErrorEnvelope.self).eraseToAnyPublisher() + return Just(pledgedProjectsData).setFailureType(to: ErrorEnvelope.self).delay( + for: 0.01, + scheduler: DispatchQueue.main + ).eraseToAnyPublisher() case let .failure(envelope): return Fail(outputType: GraphAPI.FetchPledgedProjectsQuery.Data.self, failure: envelope) diff --git a/Library/PaginatingList.swift b/Library/PaginatingList.swift index 5bb335eef1..1ab850737e 100644 --- a/Library/PaginatingList.swift +++ b/Library/PaginatingList.swift @@ -2,11 +2,16 @@ import Foundation import SwiftUI /// A List wrapper that handles pagination and refreshing -public struct PaginatingList: View where Data: Identifiable, Data: Hashable, Cell: View { +public struct PaginatingList: View where + Data: Identifiable, + Data: Hashable, + Cell: View, + Header: View { var data: [Data] = [] var canLoadMore: Bool var selectedItem: Binding? + let header: () -> Header let content: (Data) -> Cell let onRefresh: () async -> Void let onLoadMore: () async -> Void @@ -23,6 +28,7 @@ public struct PaginatingList: View where Data: Identifiable, Data: H data: [Data], canLoadMore: Bool, selectedItem: Binding? = nil, + header: @escaping () -> Header, content: @escaping (Data) -> Cell, onRefresh: @escaping () async -> Void, onLoadMore: @escaping () async -> Void @@ -30,6 +36,7 @@ public struct PaginatingList: View where Data: Identifiable, Data: H self.data = data self.canLoadMore = canLoadMore self.selectedItem = selectedItem + self.header = header self.content = content self.onRefresh = onRefresh self.onLoadMore = onLoadMore @@ -37,6 +44,7 @@ public struct PaginatingList: View where Data: Identifiable, Data: H public var body: some View { List(selection: self.selectedItem) { + self.header() ForEach(self.data) { item in self.content(item) .tag(item) @@ -80,3 +88,24 @@ public struct PaginatingList: View where Data: Identifiable, Data: H @State private var loaderID = UUID() } + +extension PaginatingList where Header == EmptyView { + public init( + data: [Data], + canLoadMore: Bool, + selectedItem: Binding? = nil, + content: @escaping (Data) -> Cell, + onRefresh: @escaping () async -> Void, + onLoadMore: @escaping () async -> Void + ) { + self.init( + data: data, + canLoadMore: canLoadMore, + selectedItem: selectedItem, + header: { () in EmptyView() }, + content: content, + onRefresh: onRefresh, + onLoadMore: onLoadMore + ) + } +} diff --git a/Library/TestHelpers/XCTestCase+AppEnvironment.swift b/Library/TestHelpers/XCTestCase+AppEnvironment.swift index 405a7009f2..421bb99ac0 100644 --- a/Library/TestHelpers/XCTestCase+AppEnvironment.swift +++ b/Library/TestHelpers/XCTestCase+AppEnvironment.swift @@ -78,4 +78,80 @@ extension XCTestCase { body: body ) } + + // MARK: Async + + // Pushes an environment onto the stack, executes a closure, and then pops the environment from the stack. + func withEnvironment(_ env: Environment, body: () async -> Void) async { + AppEnvironment.pushEnvironment(env) + await body() + AppEnvironment.popEnvironment() + } + + // Pushes an environment onto the stack, executes a closure, and then pops the environment from the stack. + func withEnvironment( + apiService: ServiceType = AppEnvironment.current.apiService, + apiDelayInterval: DispatchTimeInterval = AppEnvironment.current.apiDelayInterval, + applePayCapabilities: ApplePayCapabilitiesType = AppEnvironment.current.applePayCapabilities, + application: UIApplicationType = UIApplication.shared, + appTrackingTransparency: AppTrackingTransparencyType = AppEnvironment.current.appTrackingTransparency, + assetImageGeneratorType: AssetImageGeneratorType.Type = AppEnvironment.current.assetImageGeneratorType, + cache: KSCache = AppEnvironment.current.cache, + calendar: Calendar = AppEnvironment.current.calendar, + config: Config? = AppEnvironment.current.config, + cookieStorage: HTTPCookieStorageProtocol = AppEnvironment.current.cookieStorage, + coreTelephonyNetworkInfo: CoreTelephonyNetworkInfoType = AppEnvironment.current.coreTelephonyNetworkInfo, + countryCode: String = AppEnvironment.current.countryCode, + currentUser: User? = AppEnvironment.current.currentUser, + dateType: DateProtocol.Type = AppEnvironment.current.dateType, + debounceInterval: DispatchTimeInterval = AppEnvironment.current.debounceInterval, + device: UIDeviceType = AppEnvironment.current.device, + isVoiceOverRunning: @escaping () -> Bool = AppEnvironment.current.isVoiceOverRunning, + ksrAnalytics: KSRAnalytics = AppEnvironment.current.ksrAnalytics, + language: Language = AppEnvironment.current.language, + launchedCountries: LaunchedCountries = AppEnvironment.current.launchedCountries, + locale: Locale = AppEnvironment.current.locale, + mainBundle: NSBundleType = AppEnvironment.current.mainBundle, + pushRegistrationType: PushRegistrationType.Type = AppEnvironment.current.pushRegistrationType, + remoteConfigClient: RemoteConfigClientType? = AppEnvironment.current.remoteConfigClient, + scheduler: DateScheduler = AppEnvironment.current.scheduler, + ubiquitousStore: KeyValueStoreType = AppEnvironment.current.ubiquitousStore, + userDefaults: KeyValueStoreType = AppEnvironment.current.userDefaults, + uuidType: UUIDType.Type = AppEnvironment.current.uuidType, + body: () async -> Void + ) async { + await self.withEnvironment( + Environment( + apiService: apiService, + apiDelayInterval: apiDelayInterval, + applePayCapabilities: applePayCapabilities, + application: application, + appTrackingTransparency: appTrackingTransparency, + assetImageGeneratorType: assetImageGeneratorType, + cache: cache, + calendar: calendar, + config: config, + cookieStorage: cookieStorage, + coreTelephonyNetworkInfo: coreTelephonyNetworkInfo, + countryCode: countryCode, + currentUser: currentUser, + dateType: dateType, + debounceInterval: debounceInterval, + device: device, + isVoiceOverRunning: isVoiceOverRunning, + ksrAnalytics: ksrAnalytics, + language: language, + launchedCountries: launchedCountries, + locale: locale, + mainBundle: mainBundle, + pushRegistrationType: pushRegistrationType, + remoteConfigClient: remoteConfigClient, + scheduler: scheduler, + ubiquitousStore: ubiquitousStore, + userDefaults: userDefaults, + uuidType: uuidType + ), + body: body + ) + } }