Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions Sources/MPComponents/BaseElements/Footer/MPFooter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//
// MPFooter.swift
// MercadoPagoSDK
//
// Created by Guilherme Prata Costa on 24/11/25.
//

import SwiftUI
import MPFoundation

/// A footer component that displays payment summary information such as total amount and payment method details.
///
/// The footer consists of two main areas:
/// - **Summary Line**: Displays a label (e.g., "Total") and an amount value
/// - **Description Line**: Description label
///
/// ## Usage
///
/// ```swift
/// MPFooter(
/// label: "Total",
/// amount: "R$ 500",
/// description: "Santander Crédito **** 4561"
/// )
/// ```
///
package struct MPFooter: View {

// MARK: - Properties

private let label: String
private let amount: String
private let description: String?

// MARK: - Environment

@Environment(\.mpFooterStyle) private var style: any MPFooterStyle
@Environment(\.checkoutTheme) var theme: MPTheme

// MARK: - Initialization

/// Creates a new footer with the specified configuration.
///
/// - Parameters:
/// - label: The label to display on the left (e.g., "Total")
/// - amount: The amount value to display on the right
/// - description: Optional description to display on the right below
package init(
label: String,
amount: String,
description: String? = nil
) {
self.label = label
self.amount = amount
self.description = description
}

// MARK: - Body

package var body: some View {
let configuration = MPFooterStyleConfiguration(
summaryLine: summaryLineView,
descriptionLine: descriptionLineView,
hasDescription: description != nil
)

return AnyView(
style.makeBody(configuration: configuration)
)
}

// MARK: - Summary Line View

@ViewBuilder
private var summaryLineView: some View {
HStack(alignment: .center, spacing: theme.spacings.m) {
// Label
Text(label)
.textStyle(.bodyMediumSemibold())
.lineLimit(1)

Spacer()

// Amount
Text(amount)
.textStyle(.titleSmallSemibold())
.foregroundColor(theme.colors.textPrimary)
.lineLimit(1)
}
}

// MARK: - Description Line View

@ViewBuilder
private var descriptionLineView: some View {
if let descriptionText = description {
HStack {
Spacer()

Text(descriptionText)
.textStyle(.bodySmallRegular())
.lineLimit(1)
}
}
}
}

// MARK: - Preview

#if DEBUG
struct MPFooter_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20) {
Spacer()

// Footer with description
MPFooter(
label: "Total",
amount: "R$ 500",
description: "Santander Crédito **** 4561"
)

// Footer without description
MPFooter(
label: "Total",
amount: "R$ 1.250,00"
)
}
.loadMPFonts()
}
}
#endif

94 changes: 94 additions & 0 deletions Sources/MPComponents/BaseElements/Footer/MPFooterStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// MPFooterStyle.swift
// MercadoPagoSDK
//
// Created by Guilherme Prata Costa on 24/11/25.
//
import SwiftUI
import MPFoundation

/// A style protocol for `MPFooter` enabling custom skins.
package protocol MPFooterStyle: StyleProtocol, Identifiable where Configuration == MPFooterStyleConfiguration {}

/// Default visual style for `MPFooter` using theme tokens.
package struct MPDefaultFooterStyle: MPFooterStyle {
package var id: UUID = .init()

@Environment(\.checkoutTheme) var theme: MPTheme

package init() {}

@MainActor
package func makeBody(configuration: MPFooterStyleConfiguration) -> some View {
VStack(spacing: 0) {
// Content area
VStack(spacing: theme.spacings.xs) {
// Summary line
configuration.summaryLine
.padding(.horizontal, theme.spacings.m)
.padding(.top, theme.spacings.m)

// Description line (if present)
if configuration.hasDescription {
configuration.descriptionLine
.padding(.horizontal, theme.spacings.m)
.padding(.bottom, theme.spacings.m)
} else {
Color.clear
.frame(height: theme.spacings.m)
}
}
.background(theme.colors.backgroundPrimary)
.background(
theme.colors.backgroundPrimary
.shadow(
color: Color.black.opacity(0.1),
radius: 8, x: 0, y: -4
)
.mask(
Rectangle()
.padding(.top, -20)
)
)
}
}
}

// MARK: - Style Resolution
package extension MPFooterStyle {
@MainActor
func resolve(configuration: Configuration) -> some View {
ResolvedMPFooterStyle(style: self, configuration: configuration)
}
}

private struct ResolvedMPFooterStyle<Style: MPFooterStyle>: View {
let style: Style
let configuration: Style.Configuration

var body: some View {
style.makeBody(configuration: configuration)
}
}

// MARK: - Environment
private struct MPFooterStyleKey: @preconcurrency EnvironmentKey {
@MainActor
static var defaultValue: any MPFooterStyle = MPDefaultFooterStyle()
}

extension EnvironmentValues {
var mpFooterStyle: any MPFooterStyle {
get { self[MPFooterStyleKey.self] }
set { self[MPFooterStyleKey.self] = newValue }
}
}

package extension View {
/// Sets the style for `MPFooter` within this view hierarchy.
///
/// - Parameter style: The `MPFooterStyle` to apply.
func mpFooterStyle<S: MPFooterStyle>(_ style: S) -> some View {
environment(\.mpFooterStyle, style)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// MPFooterStyleConfiguration.swift
// MercadoPagoSDK
//
// Created by Guilherme Prata Costa on 24/11/25.
//
import SwiftUI

/// Configuration passed to `MPFooterStyle` for rendering.
package struct MPFooterStyleConfiguration {

// MARK: - Subviews

package struct SummaryLine: View {
package let body: AnyView
}

package struct DescriptionLine: View {
package let body: AnyView
}

// MARK: - Properties

/// Summary line view (with label and amount)
package let summaryLine: SummaryLine

/// Description line view (optional card/payment info)
package let descriptionLine: DescriptionLine

/// Whether the footer has description information
package let hasDescription: Bool

// MARK: - Initialization

@MainActor
package init(
summaryLine: some View,
descriptionLine: some View,
hasDescription: Bool
) {
self.summaryLine = SummaryLine(body: AnyView(summaryLine))
self.descriptionLine = DescriptionLine(body: AnyView(descriptionLine))
self.hasDescription = hasDescription
}
}

29 changes: 20 additions & 9 deletions Sources/MPComponents/BaseElements/Header/MPHeader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ import MPFoundation
/// }
/// ```
///
package struct MPHeader<Content: View, TrailingActions: View>: View {
package struct MPHeader<Content: View, TrailingActions: View, Footer: View>: View {

// MARK: - Properties

private let title: String
private let onBack: () -> Void
private let trailingActions: TrailingActions
private let content: Content
private let footer: Footer

// MARK: - Environment

Expand Down Expand Up @@ -71,11 +72,13 @@ package struct MPHeader<Content: View, TrailingActions: View>: View {
title: String,
onBack: @escaping () -> Void = {},
@ViewBuilder trailingActions: () -> TrailingActions = { EmptyView() },
@ViewBuilder footer: () -> Footer = { EmptyView() },
@ViewBuilder content: () -> Content
) {
self.title = title
self.onBack = onBack
self.trailingActions = trailingActions()
self.footer = footer()
self.content = content()
}

Expand All @@ -85,15 +88,21 @@ package struct MPHeader<Content: View, TrailingActions: View>: View {
GeometryReader { geometry in
ZStack(alignment: .top) {
// Scrollable content with offset tracking
ScrollViewWithOffset(offset: $scrollOffset) {
VStack(spacing: 0) {
Color.clear
.frame(height: headerInset(safeAreaTop: geometry.safeAreaInsets.top))

content

VStack(spacing: 0) {
ScrollViewWithOffset(offset: $scrollOffset) {
VStack(spacing: 0) {
Color.clear
.frame(height: headerInset(safeAreaTop: geometry.safeAreaInsets.top))

content
}
}

footer
.zIndex(2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity)

// Header container
VStack(spacing: 0) {
Expand Down Expand Up @@ -194,12 +203,14 @@ extension MPHeader where TrailingActions == EmptyView {
package init(
title: String,
onBack: @escaping () -> Void = {},
@ViewBuilder content: () -> Content
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.init(
title: title,
onBack: onBack,
trailingActions: { EmptyView() },
footer: footer,
content: content
)
}
Expand Down