mirror of
https://github.com/EmergeTools/Pow.git
synced 2026-03-25 08:55:50 +00:00
Co-authored-by: Robert Böhnke <robb@robb.is> Co-authored-by: Kasper Lahti <kasper@lahti.email>
275 lines
9.2 KiB
Swift
275 lines
9.2 KiB
Swift
import Pow
|
||
import SwiftUI
|
||
|
||
struct CheckoutExample: View, Example {
|
||
enum PaymentError: Error {
|
||
case unknown
|
||
}
|
||
|
||
@State
|
||
var result: Result<Void, Error>?
|
||
|
||
@State
|
||
var quantity = 1
|
||
|
||
var body: some View {
|
||
List {
|
||
if case .success = result {
|
||
VStack(alignment: .leading) {
|
||
Text("Thank You For Your Order")
|
||
.font(.title2)
|
||
.bold()
|
||
Text("We'll notify you when your order has been sent.")
|
||
.font(.title3)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.listRowSeparator(.hidden)
|
||
|
||
Spacer()
|
||
|
||
LabeledContent("Purchase Number", value: "P023121114")
|
||
} else {
|
||
VStack(alignment: .leading) {
|
||
Text("Checkout")
|
||
.font(.largeTitle)
|
||
.bold()
|
||
.accessibility(addTraits: .isHeader)
|
||
Text(quantity > 0 ? "1 Item" : "No Items")
|
||
.font(.title2)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.listRowSeparator(.hidden)
|
||
|
||
Spacer()
|
||
|
||
Section {
|
||
if quantity > 0 {
|
||
CartItem(quantity: $quantity)
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
Section {
|
||
LabeledContent("Address", value: "jane.doe@example.com")
|
||
|
||
LabeledContent("Payment", value: "VISA")
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.navigationTitle("")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.animation(.default, value: quantity != 0)
|
||
.toolbar {
|
||
if quantity == 0 {
|
||
ToolbarItem(placement: .navigationBarTrailing) {
|
||
Button("Undo") {
|
||
quantity = 1
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.changeEffect(.feedback(SoundEffect("whop")), value: quantity == 0, isEnabled: quantity == 0)
|
||
.changeEffect(.feedback(SoundEffect("wip")), value: quantity != 0, isEnabled: quantity != 0)
|
||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||
VStack(spacing: 16) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
LabeledContent("Subtotal", value: 99 * quantity, format: .currency(code: "USD"))
|
||
.foregroundStyle(.secondary)
|
||
LabeledContent("Shipping", value: 0, format: .currency(code: "USD"))
|
||
.foregroundStyle(.secondary)
|
||
LabeledContent("Total", value: 99 * quantity, format: .currency(code: "USD"))
|
||
.fontWeight(.heavy)
|
||
}
|
||
|
||
PayButton {
|
||
let isFirstPayAttempt = result == nil
|
||
|
||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||
|
||
if isFirstPayAttempt {
|
||
throw PaymentError.unknown
|
||
}
|
||
} completion: { payResult in
|
||
withAnimation {
|
||
result = payResult
|
||
}
|
||
}
|
||
.disabled(quantity == 0)
|
||
.changeEffect(.shine.delay(1), value: quantity != 0, isEnabled: quantity != 0)
|
||
}
|
||
.padding()
|
||
.background(.bar)
|
||
}
|
||
.labeledContentStyle(CheckoutLabeledContentStyle())
|
||
}
|
||
|
||
static let localPath = LocalPath()
|
||
|
||
static var icon: Image? {
|
||
Image(systemName: "cart")
|
||
}
|
||
|
||
static let newIn0_2_0: Bool = true
|
||
}
|
||
|
||
private struct CartItem: View {
|
||
@Binding
|
||
var quantity: Int
|
||
|
||
@State
|
||
var lastQuantity: Int = 0
|
||
|
||
var body: some View {
|
||
HStack {
|
||
Stepper("Quantity", value: $quantity, in: 0...99)
|
||
.labelsHidden()
|
||
.alignmentGuide(.listRowSeparatorLeading) { dimensions in
|
||
dimensions[.leading]
|
||
}
|
||
.changeEffect(.feedback(SoundEffect("beep")), value: quantity, isEnabled: quantity > lastQuantity && quantity > 1)
|
||
.changeEffect(.feedback(SoundEffect("boop")), value: quantity, isEnabled: quantity < lastQuantity && quantity > 0)
|
||
.onChange(of: quantity) { newValue in
|
||
lastQuantity = quantity
|
||
}
|
||
|
||
Text(quantity, format: .number).monospacedDigit() + Text("×")
|
||
|
||
LabeledContent("Pow License", value: 99, format: .currency(code: "USD"))
|
||
}
|
||
}
|
||
}
|
||
|
||
struct PayButton: View {
|
||
var action: () async throws -> Void
|
||
|
||
var completion: (Result<Void, Error>) -> Void
|
||
|
||
enum Status {
|
||
case initial
|
||
case inProgress
|
||
case succeeded
|
||
case failed
|
||
}
|
||
|
||
@State
|
||
var status: Status = .initial
|
||
|
||
var body: some View {
|
||
Button {
|
||
status = .inProgress
|
||
Task {
|
||
do {
|
||
try await action()
|
||
status = .succeeded
|
||
completion(.success(()))
|
||
} catch {
|
||
status = .failed
|
||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||
status = .initial
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
} label: {
|
||
HStack(spacing: 12) {
|
||
ZStack {
|
||
ProgressView()
|
||
.controlSize(.regular)
|
||
.tint(.white)
|
||
.opacity(status == .inProgress ? 1 : 0)
|
||
.animation(.spring(), value: status == .inProgress)
|
||
|
||
Image(systemName: "exclamationmark.triangle")
|
||
.opacity(status == .failed ? 1 : 0)
|
||
.animation(.spring(), value: status == .failed)
|
||
|
||
Checkmark()
|
||
.trim(from: 0, to: status == .succeeded ? 1 : 0)
|
||
.stroke(style: .init(lineWidth: 3, lineCap: .round, lineJoin: .round))
|
||
.padding(4)
|
||
.animation(.spring(response: 0.3), value: status == .succeeded)
|
||
}
|
||
.frame(width: 20, height: 20)
|
||
.imageScale(.large)
|
||
|
||
Spacer()
|
||
|
||
switch status {
|
||
case .initial:
|
||
Text("Pay")
|
||
case .inProgress:
|
||
Text("Paying…")
|
||
case .succeeded:
|
||
Text("Paid")
|
||
case .failed:
|
||
Text("Try Again")
|
||
}
|
||
|
||
Color.clear
|
||
.frame(width: 20, height: 20)
|
||
|
||
Spacer()
|
||
}
|
||
}
|
||
.font(.headline)
|
||
.buttonStyle(.borderedProminent)
|
||
.transformEnvironment(\.backgroundMaterial, transform: { material in
|
||
material = nil
|
||
})
|
||
.controlSize(.large)
|
||
.animation(.spring(response: 0.3), value: status == .inProgress)
|
||
.tint(status == .failed ? .red : status == .succeeded ? .green : nil)
|
||
.allowsHitTesting(status == .initial)
|
||
.changeEffect(.shake(rate: .fast), value: status == .failed, isEnabled: status == .failed)
|
||
.changeEffect(.feedback(SoundEffect("plop")), value: status == .inProgress, isEnabled: status == .inProgress)
|
||
.changeEffect(.feedback(SoundEffect("sparkle")), value: status == .succeeded, isEnabled: status == .succeeded)
|
||
.changeEffect(.feedback(SoundEffect("notfound")), value: status == .failed, isEnabled: status == .failed)
|
||
}
|
||
}
|
||
|
||
private struct Checkmark: Shape {
|
||
func path(in rect: CGRect) -> Path {
|
||
let insetFrame = rect
|
||
|
||
let referenceSize = CGSize(width: 67, height: 68)
|
||
let referencePoint1: CGPoint
|
||
let referencePoint2: CGPoint
|
||
let referencePoint3: CGPoint
|
||
|
||
referencePoint1 = CGPoint(x: 3.5, y: 36.5)
|
||
referencePoint2 = CGPoint(x: 25.5, y: 63.5)
|
||
referencePoint3 = CGPoint(x: 63, y: 5.5)
|
||
|
||
return Path { path in
|
||
path.move(to: CGPoint(x: insetFrame.width * referencePoint1.x / referenceSize.width, y: insetFrame.height * referencePoint1.y / referenceSize.height))
|
||
path.addLine(to: CGPoint(x: insetFrame.width * referencePoint2.x / referenceSize.width, y: insetFrame.width * referencePoint2.y / referenceSize.height))
|
||
path.addLine(to: CGPoint(x: insetFrame.width * referencePoint3.x / referenceSize.width, y: insetFrame.width * referencePoint3.y / referenceSize.height))
|
||
}
|
||
.offsetBy(dx: insetFrame.origin.x, dy: insetFrame.origin.y)
|
||
}
|
||
}
|
||
|
||
private struct CheckoutLabeledContentStyle: LabeledContentStyle {
|
||
func makeBody(configuration: Configuration) -> some View {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
configuration.label
|
||
.font(.headline)
|
||
Spacer()
|
||
configuration.content
|
||
.font(.body)
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
.padding(.vertical, 8)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
struct CheckoutExample_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
NavigationStack {
|
||
CheckoutExample()
|
||
.toolbar(.visible, for: .navigationBar)
|
||
}
|
||
}
|
||
}
|