diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index cd0738c03c..9cecc083cf 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -366,6 +366,7 @@ 4DBF1F372B4D572400D52354 /* LocalReceiptFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DBF1F352B4D572400D52354 /* LocalReceiptFetcher.swift */; }; 4DC546272AD44BBE005CDB35 /* EncodedAppleReceipt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */; }; 4DE3D5742CDB646900838110 /* MockPaywallEventsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FFFE6C52AA9465000B2955C /* MockPaywallEventsManager.swift */; }; + 4DEB9BC52D08CA1700D33E36 /* BadgeModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DEB9BC42D08CA1500D33E36 /* BadgeModifier.swift */; }; 4F0201C42A13C85500091612 /* Assertions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F0201C32A13C85500091612 /* Assertions.swift */; }; 4F05876F2A5DE03F00E9A834 /* PaywallDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */; }; 4F062D322A85A11600A8A613 /* PaywallData+Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */; }; @@ -1690,6 +1691,7 @@ 4DBC30952B1DFA97001D33C7 /* StoreKitVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitVersion.swift; sourceTree = ""; }; 4DBF1F352B4D572400D52354 /* LocalReceiptFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalReceiptFetcher.swift; sourceTree = ""; }; 4DC546262AD44BBE005CDB35 /* EncodedAppleReceipt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EncodedAppleReceipt.swift; sourceTree = ""; }; + 4DEB9BC42D08CA1500D33E36 /* BadgeModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeModifier.swift; sourceTree = ""; }; 4F0201C32A13C85500091612 /* Assertions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Assertions.swift; sourceTree = ""; }; 4F05876E2A5DE03F00E9A834 /* PaywallDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallDataTests.swift; sourceTree = ""; }; 4F062D312A85A11600A8A613 /* PaywallData+Localization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PaywallData+Localization.swift"; sourceTree = ""; }; @@ -2561,6 +2563,7 @@ 2C7457872CEDF7AC004ACE52 /* BackgroundStyle.swift */, 77089F9D2CD39EC100848CD5 /* ShadowModifier.swift */, 4D6F4BCF2CF69DE300353AF6 /* ForegroundColorScheme.swift */, + 4DEB9BC42D08CA1500D33E36 /* BadgeModifier.swift */, 2C91068D2CE2481800189565 /* SizeModifier.swift */, 2CAB87F62CAAB13200247013 /* Shape.swift */, 03C72F8C2D3311D500297FEC /* DisplayableColor.swift */, @@ -6601,6 +6604,7 @@ 3546355F2C391F4D001D7E85 /* PromotionalOfferView.swift in Sources */, 2C7457882CEDF7C0004ACE52 /* BackgroundStyle.swift in Sources */, 2C8EC6DD2CCC7C5B00D6CCF8 /* PackageValidator.swift in Sources */, + 4DEB9BC52D08CA1700D33E36 /* BadgeModifier.swift in Sources */, 778360792CCA85E4000785B8 /* StickyFooterComponentViewModel.swift in Sources */, 2C7457482CEA66AB004ACE52 /* ComponentsView.swift in Sources */, 353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift index eec364d65c..66088e347c 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentView.swift @@ -99,6 +99,7 @@ struct StackComponentView: View { shadow: style.shadow, background: style.backgroundStyle, uiConfigProvider: self.viewModel.uiConfigProvider) + .stackBadge(style.badge) .padding(style.margin) } @@ -528,7 +529,9 @@ fileprivate extension StackComponentViewModel { try self.init( component: component, viewModels: viewModels, - uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()) + badgeViewModels: [], + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()), + localizationProvider: localizationProvider ) } diff --git a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift index 9b874ad9d7..79db01aaaf 100644 --- a/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift +++ b/RevenueCatUI/Templates/V2/Components/Stack/StackComponentViewModel.swift @@ -26,16 +26,19 @@ class StackComponentViewModel { private let presentedOverrides: PresentedOverrides? let viewModels: [PaywallComponentViewModel] + let badgeViewModels: [PaywallComponentViewModel] init( component: PaywallComponent.StackComponent, viewModels: [PaywallComponentViewModel], - uiConfigProvider: UIConfigProvider + badgeViewModels: [PaywallComponentViewModel], + uiConfigProvider: UIConfigProvider, + localizationProvider: LocalizationProvider ) throws { self.component = component self.viewModels = viewModels self.uiConfigProvider = uiConfigProvider - + self.badgeViewModels = badgeViewModels self.presentedOverrides = try self.component.overrides?.toPresentedOverrides { $0 } } @@ -55,6 +58,7 @@ class StackComponentViewModel { let style = StackComponentStyle( uiConfigProvider: self.uiConfigProvider, + badgeViewModels: self.badgeViewModels, visible: partial?.visible ?? true, dimension: partial?.dimension ?? self.component.dimension, size: partial?.size ?? self.component.size, @@ -64,7 +68,8 @@ class StackComponentViewModel { margin: partial?.margin ?? self.component.margin, shape: partial?.shape ?? self.component.shape, border: partial?.border ?? self.component.border, - shadow: partial?.shadow ?? self.component.shadow + shadow: partial?.shadow ?? self.component.shadow, + badge: partial?.badge ?? self.component.badge ) apply(style) @@ -109,9 +114,11 @@ struct StackComponentStyle { let shape: ShapeModifier.Shape? let border: ShapeModifier.BorderInfo? let shadow: ShadowModifier.ShadowInfo? + let badge: BadgeModifier.BadgeInfo? init( uiConfigProvider: UIConfigProvider, + badgeViewModels: [PaywallComponentViewModel], visible: Bool, dimension: PaywallComponent.Dimension, size: PaywallComponent.Size, @@ -121,7 +128,8 @@ struct StackComponentStyle { margin: PaywallComponent.Padding, shape: PaywallComponent.Shape?, border: PaywallComponent.Border?, - shadow: PaywallComponent.Shadow? + shadow: PaywallComponent.Shadow?, + badge: PaywallComponent.Badge? ) { self.visible = visible self.dimension = dimension @@ -133,6 +141,9 @@ struct StackComponentStyle { self.shape = shape?.shape self.border = border?.border(uiConfigProvider: uiConfigProvider) self.shadow = shadow?.shadow(uiConfigProvider: uiConfigProvider) + self.badge = badge?.badge(stackShape: self.shape, + badgeViewModels: badgeViewModels, + uiConfigProvider: uiConfigProvider) } var vstackStrategy: StackStrategy { @@ -168,7 +179,7 @@ struct StackComponentStyle { @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension PaywallComponent.Shape { - var shape: ShapeModifier.Shape? { + var shape: ShapeModifier.Shape { switch self { case .rectangle(let cornerRadiuses): let corners = cornerRadiuses.flatMap { cornerRadiuses in @@ -213,4 +224,22 @@ private extension PaywallComponent.Shadow { } +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension PaywallComponent.Badge { + + func badge(stackShape: ShapeModifier.Shape?, + badgeViewModels: [PaywallComponentViewModel], + uiConfigProvider: UIConfigProvider) -> BadgeModifier.BadgeInfo? { + BadgeModifier.BadgeInfo( + style: self.style, + alignment: self.alignment, + stack: self.stack, + badgeViewModels: badgeViewModels, + stackShape: stackShape, + uiConfigProvider: uiConfigProvider + ) + } + +} + #endif diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift new file mode 100644 index 0000000000..1419f0dab0 --- /dev/null +++ b/RevenueCatUI/Templates/V2/ViewHelpers/BadgeModifier.swift @@ -0,0 +1,465 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// BadgeModifier.swift +// +// Created by Mark Villacampa 09/12/2024. + +// swiftlint:disable file_length + +import RevenueCat +import SwiftUI + +#if PAYWALL_COMPONENTS + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeModifier: ViewModifier { + + let badge: BadgeInfo? + + struct BadgeInfo { + let style: PaywallComponent.BadgeStyle + let alignment: PaywallComponent.TwoDimensionAlignment + let stack: PaywallComponent.CodableBox + let badgeViewModels: [PaywallComponentViewModel] + let stackShape: ShapeModifier.Shape? + let uiConfigProvider: UIConfigProvider + + var backgroundStyle: BackgroundStyle? { + stack.value.backgroundColor?.asDisplayable(uiConfigProvider: uiConfigProvider).backgroundStyle + } + } + + func body(content: Content) -> some View { + if let badge = badge { + content.apply(badge: badge) + } else { + content + } + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +fileprivate extension View { + + @ViewBuilder + func apply(badge: BadgeModifier.BadgeInfo) -> some View { + switch badge.style { + case .edgeToEdge: + self.applyBadgeEdgeToEdge(badge: badge) + case .overlaid: + self.overlay( + VStack(alignment: .leading) { + VStack { + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } + .fixedSize() + .padding(effectiveMargin(badge: badge).edgeInsets) + .alignmentGuide( + effetiveVerticalAlinmentForOverlaidBadge(alignment: badge.alignment.stackAlignment), + computeValue: { dim in dim[VerticalAlignment.center] }) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + case .nested: + self.overlay( + VStack(alignment: .leading) { + VStack { + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } + + .fixedSize() + .padding(effectiveMargin(badge: badge).edgeInsets) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + } + } + + // Helper to apply the edge-to-edge badge style + @ViewBuilder + // swiftlint:disable:next function_body_length + private func applyBadgeEdgeToEdge(badge: BadgeModifier.BadgeInfo) -> some View { + switch badge.alignment { + case .bottom: + self.background( + VStack(alignment: .leading) { + VStack { + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } + .alignmentGuide(.bottom) { dim in dim[VerticalAlignment.top] } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + .background( + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .fill(Color.clear) + Rectangle() + .fill(Color.clear) + .backgroundStyle(badge.backgroundStyle) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + case .top: + self.background( + VStack(alignment: .leading) { + VStack { + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } + .alignmentGuide(.top) { dim in dim[VerticalAlignment.bottom] } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + .background( + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .fill(Color.clear) + .backgroundStyle(badge.backgroundStyle) + Rectangle() + .fill(Color.clear) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + ) + case .bottomLeading, .bottomTrailing, .topLeading, .topTrailing: + self.overlay( + VStack(alignment: .leading) { + VStack { + ComponentsView(componentViewModels: badge.badgeViewModels, onDismiss: {}) + .backgroundStyle(badge.backgroundStyle) + .shape(border: nil, shape: effectiveShape(badge: badge)) + } + .fixedSize() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: badge.alignment.stackAlignment) + ) + default: + self + } + } + + // Helper to calculate the position of an overlaid badge at the top or bottom of the stack + private func effetiveVerticalAlinmentForOverlaidBadge(alignment: Alignment) -> VerticalAlignment { + return switch alignment { + case .top, .topLeading, .topTrailing: + VerticalAlignment.top + case .bottom, .bottomLeading, .bottomTrailing: + VerticalAlignment.bottom + default: + VerticalAlignment.top + } + } + + // Helper to calculate the effective margins of a badge depending on its type: + // - Edge-to-ege: No margin allowed. + // - Overlaid: Only leading/trailing margins allowed if in the leading/trailing positions respectively. + // - Nested: Margin only allowed in the sides adjacent to the stack borders. + // swiftlint:disable:next cyclomatic_complexity + private func effectiveMargin(badge: BadgeModifier.BadgeInfo) -> PaywallComponent.Padding { + switch badge.style { + case .edgeToEdge: + return .zero + case .overlaid: + switch badge.alignment { + case .top, .bottom, .center: + return .zero + case .leading, .topLeading, .bottomLeading: + return .init(top: 0, bottom: 0, leading: badge.stack.value.margin.leading, trailing: 0) + case .trailing, .topTrailing, .bottomTrailing: + return .init(top: 0, bottom: 0, leading: 0, trailing: badge.stack.value.margin.trailing) + } + case .nested: + switch badge.alignment { + case .center, .leading, .trailing: + return .zero + case .top: + return .init(top: badge.stack.value.margin.top, bottom: 0, leading: 0, trailing: 0) + case .bottom: + return .init(top: 0, bottom: badge.stack.value.margin.bottom, leading: 0, trailing: 0) + case .topLeading: + return .init(top: badge.stack.value.margin.top, bottom: 0, + leading: badge.stack.value.margin.leading, trailing: 0) + case .topTrailing: + return .init(top: badge.stack.value.margin.top, bottom: 0, + leading: 0, trailing: badge.stack.value.margin.trailing) + case .bottomLeading: + return .init(top: 0, bottom: badge.stack.value.margin.bottom, + leading: badge.stack.value.margin.leading, trailing: 0) + case .bottomTrailing: + return .init(top: 0, bottom: badge.stack.value.margin.bottom, + leading: 0, trailing: badge.stack.value.margin.trailing) + } + } + } + + // Helper to calculate the shape of the edge-to-edge badge in trailing/leading positions. + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func effectiveShape(badge: BadgeModifier.BadgeInfo) -> ShapeModifier.Shape? { + switch badge.style { + case .edgeToEdge: + switch badge.stack.value.shape { + case .pill, .none: + // Edge-to-edge badge cannot have pill shape + return nil + case .rectangle(let corners): + switch badge.alignment { + case .center, .leading, .trailing: + return nil + case .top: + return .rectangle(.init( + topLeft: corners?.topLeading, + topRight: corners?.topTrailing, + bottomLeft: 0, + bottomRight: 0)) + case .bottom: + return .rectangle(.init( + topLeft: 0, + topRight: 0, + bottomLeft: corners?.bottomLeading, + bottomRight: corners?.bottomTrailing)) + case .topLeading: + return .rectangle(.init( + topLeft: radiusInfo(shape: badge.stackShape)?.topLeft, + topRight: 0, + bottomLeft: 0, + bottomRight: corners?.bottomTrailing)) + case .topTrailing: + return .rectangle(.init( + topLeft: 0.0, + topRight: radiusInfo(shape: badge.stackShape)?.topRight, + bottomLeft: corners?.bottomLeading, + bottomRight: 0)) + case .bottomLeading: + return .rectangle(.init( + topLeft: 0.0, + topRight: corners?.topTrailing, + bottomLeft: radiusInfo(shape: badge.stackShape)?.bottomLeft, + bottomRight: 0)) + case .bottomTrailing: + return .rectangle(.init( + topLeft: corners?.topLeading, + topRight: 0, + bottomLeft: 0, + bottomRight: radiusInfo(shape: badge.stackShape)?.bottomRight)) + } + } + case .nested, .overlaid: + switch badge.stack.value.shape { + case .rectangle(let radius): + return .rectangle(.init(topLeft: radius?.topLeading, + topRight: radius?.topTrailing, + bottomLeft: radius?.bottomLeading, + bottomRight: radius?.bottomTrailing)) + case .pill: + return .pill + case .none: + return nil + } + } + } + + // Helper to extract the RadiusInfo from a rectanle shape + private func radiusInfo(shape: ShapeModifier.Shape?) -> ShapeModifier.RadiusInfo? { + switch shape { + case .rectangle(let radius): + return radius + default: + return nil + } + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension View { + func stackBadge(_ badge: BadgeModifier.BadgeInfo?) -> some View { + self.modifier(BadgeModifier(badge: badge)) + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +@ViewBuilder +// swiftlint:disable:next function_body_length +private func badge(style: PaywallComponent.BadgeStyle, alignment: PaywallComponent.TwoDimensionAlignment) -> some View { + VStack(spacing: 16) { + Text("Standard") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.black) + + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Feature 1") + .foregroundColor(.black) + } + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Feature 2") + .foregroundColor(.black) + } + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Feature 3") + .foregroundColor(.black) + } + } + + Text("$9.99/month") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.black) + + Text("Includes 7 Day Free Trial") + .font(.caption) + .foregroundColor(.gray) + + Text("Continue") + .fontWeight(.bold) + .frame(maxWidth: .infinity) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + + } + .padding() + .padding(.vertical, 34) + .backgroundStyle(.color(.init(light: .hex("#ffffff"))).backgroundStyle) + .shape( + border: .init(color: .blue, width: 10), + shape: .rectangle(ShapeModifier.RadiusInfo(topLeft: 12.0, topRight: 12, bottomLeft: 12, bottomRight: 12)) + ) + .compositingGroup() + .shadow(color: Color.black.opacity(0.5), radius: 4, x: 0, y: 4) + .stackBadge( + BadgeModifier.BadgeInfo( + style: style, + alignment: alignment, + stack: PaywallComponent.CodableBox(PaywallComponent.StackComponent( + components: [ + PaywallComponent.text( + PaywallComponent.TextComponent( + text: "id_1", + fontName: nil, + fontWeight: .bold, + color: .init(light: .hex("#000000")), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .zero, + fontSize: .bodyS, + horizontalAlignment: .center + ) + ) + ], + backgroundColor: .init(light: .hex("#FA8072")), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .init(top: 10, bottom: 10, leading: 10, trailing: 10), + shape: .rectangle(.init(topLeading: 8.0, topTrailing: 8, bottomLeading: 8, bottomTrailing: 8)) + )), badgeViewModels: [ + .text( + // swiftlint:disable:next force_try + try! TextComponentViewModel( + localizationProvider: .init( + locale: Locale.current, + localizedStrings: [ + "id_1": .string("Special Discount\nSave 50%") + ] + ), + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()), + component: PaywallComponent.TextComponent( + text: "id_1", + fontName: nil, + fontWeight: .bold, + color: .init(light: .hex("#000000")), + padding: .init(top: 4, bottom: 4, leading: 16, trailing: 16), + margin: .zero, + fontSize: .bodyS, + horizontalAlignment: .center + ) + ) + ) + ], + stackShape: .rectangle(.init(topLeft: 12.0, topRight: 12.0, bottomLeft: 12.0, bottomRight: 12.0)), + uiConfigProvider: .init(uiConfig: PreviewUIConfig.make()) + ) + ) +} + +// As of Xcode 16, there is a limit of 15 views per PreviewProvider. +// To work around this, we can create multiple PreviewProviders with different sets of previews. + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeEdgeToEdge_Previews: PreviewProvider { + + static var previews: some View { + let alignments: [PaywallComponent.TwoDimensionAlignment] = [ + .topLeading, .top, .topTrailing, .bottomLeading, .bottom, .bottomTrailing + ] + ForEach(alignments, id: \.self) { alignment in + badge(style: .edgeToEdge, alignment: alignment) + .previewDisplayName("edgeToEdge - \(alignment)") + } + .previewLayout(.sizeThatFits) + .padding(30) + .padding(.vertical, 50) + .previewRequiredEnvironmentProperties() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeOverlaid_Previews: PreviewProvider { + + static var previews: some View { + let alignments: [PaywallComponent.TwoDimensionAlignment] = [ + .topLeading, .top, .topTrailing, .bottomLeading, .bottom, .bottomTrailing + ] + ForEach(alignments, id: \.self) { alignment in + badge(style: .overlaid, alignment: alignment) + .previewDisplayName("overlaid - \(alignment)") + } + .previewLayout(.sizeThatFits) + .padding(30) + .padding(.vertical, 50) + .previewRequiredEnvironmentProperties() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct BadgeNested_Previews: PreviewProvider { + + static var previews: some View { + let alignments: [PaywallComponent.TwoDimensionAlignment] = [ + .topLeading, .top, .topTrailing, .bottomLeading, .bottom, .bottomTrailing + ] + ForEach(alignments, id: \.self) { alignment in + badge(style: .nested, alignment: alignment) + .previewDisplayName("nested - \(alignment)") + } + .previewLayout(.sizeThatFits) + .padding(30) + .padding(.vertical, 50) + .previewRequiredEnvironmentProperties() + } + +} + +#endif diff --git a/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift b/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift index bee22a2e12..1c8f931d16 100644 --- a/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift +++ b/RevenueCatUI/Templates/V2/ViewHelpers/Shape.swift @@ -44,17 +44,10 @@ struct ShapeModifier: ViewModifier { struct RadiusInfo: Hashable { - let topLeft: CGFloat? - let topRight: CGFloat? - let bottomLeft: CGFloat? - let bottomRight: CGFloat? - - init(topLeft: Double? = nil, topRight: Double? = nil, bottomLeft: Double? = nil, bottomRight: Double? = nil) { - self.topLeft = topLeft.flatMap { CGFloat($0) } - self.topRight = topRight.flatMap { CGFloat($0) } - self.bottomLeft = bottomLeft.flatMap { CGFloat($0) } - self.bottomRight = bottomRight.flatMap { CGFloat($0) } - } + let topLeft: Double? + let topRight: Double? + let bottomLeft: Double? + let bottomRight: Double? } diff --git a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift index 2988f4ff90..c6ef84259d 100644 --- a/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift +++ b/RevenueCatUI/Templates/V2/ViewModelHelpers/ViewModelFactory.swift @@ -90,10 +90,22 @@ struct ViewModelFactory { ) } + let badgeViewModels = try component.badge?.stack.value.components.map { component in + try self.toViewModel( + component: component, + packageValidator: packageValidator, + offering: offering, + localizationProvider: localizationProvider, + uiConfigProvider: uiConfigProvider + ) + } + return .stack( try StackComponentViewModel(component: component, viewModels: viewModels, - uiConfigProvider: uiConfigProvider) + badgeViewModels: badgeViewModels ?? [], + uiConfigProvider: uiConfigProvider, + localizationProvider: localizationProvider) ) case .button(let component): let stackViewModel = try toStackViewModel( @@ -177,7 +189,9 @@ struct ViewModelFactory { return try StackComponentViewModel( component: component, viewModels: viewModels, - uiConfigProvider: uiConfigProvider + badgeViewModels: [], + uiConfigProvider: uiConfigProvider, + localizationProvider: localizationProvider ) } diff --git a/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift b/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift index 68f70d4384..4420f0f6c9 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentPropertyTypes.swift @@ -392,7 +392,7 @@ public extension PaywallComponent { } - enum TwoDimensionAlignment: String, Decodable, Sendable, Hashable, Equatable { + enum TwoDimensionAlignment: String, Codable, Sendable, Hashable, Equatable { case center case leading @@ -461,6 +461,53 @@ public extension PaywallComponent { } + enum BadgeStyle: String, Codable, Sendable, Hashable, Equatable { + + case edgeToEdge = "edge_to_edge" + case overlaid = "overlaid" + case nested = "nested" + + } + + struct Badge: Codable, Sendable, Hashable, Equatable { + + public let style: BadgeStyle + public let alignment: TwoDimensionAlignment + public let stack: CodableBox + + } + + // Holds a reference to a `Codable` value. + final class CodableBox: Codable { + + public let value: T + + public init(_ value: T) { self.value = value } + + public required init(from decoder: Decoder) throws { + value = try T(from: decoder) + } + + public func encode(to encoder: Encoder) throws { + try value.encode(to: encoder) + } + + } + +} + +extension PaywallComponent.CodableBox: Sendable where T: Sendable {} + +extension PaywallComponent.CodableBox: Equatable where T: Equatable { + public static func == (lhs: PaywallComponent.CodableBox, rhs: PaywallComponent.CodableBox) -> Bool { + return lhs.value == rhs.value + } +} + +extension PaywallComponent.CodableBox: Hashable where T: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(value) + } } #endif diff --git a/Sources/Paywalls/Components/PaywallStackComponent.swift b/Sources/Paywalls/Components/PaywallStackComponent.swift index 3ae3d877f5..226434b119 100644 --- a/Sources/Paywalls/Components/PaywallStackComponent.swift +++ b/Sources/Paywalls/Components/PaywallStackComponent.swift @@ -31,6 +31,7 @@ public extension PaywallComponent { public let shape: Shape? public let border: Border? public let shadow: Shadow? + public let badge: Badge? public let overrides: ComponentOverrides? @@ -45,6 +46,7 @@ public extension PaywallComponent { shape: Shape? = nil, border: Border? = nil, shadow: Shadow? = nil, + badge: Badge? = nil, overrides: ComponentOverrides? = nil ) { self.components = components @@ -58,6 +60,7 @@ public extension PaywallComponent { self.shape = shape self.border = border self.shadow = shadow + self.badge = badge self.overrides = overrides } @@ -75,6 +78,7 @@ public extension PaywallComponent { public let shape: Shape? public let border: Border? public let shadow: Shadow? + public let badge: Badge? public init( visible: Bool? = true, @@ -86,7 +90,8 @@ public extension PaywallComponent { margin: Padding? = nil, shape: Shape? = nil, border: Border? = nil, - shadow: Shadow? = nil + shadow: Shadow? = nil, + badge: Badge? = nil ) { self.visible = visible self.size = size @@ -98,6 +103,7 @@ public extension PaywallComponent { self.shape = shape self.border = border self.shadow = shadow + self.badge = badge } }