migration
This commit is contained in:
244
PediFoods/Views/Main/SavedCardsView.swift
Normal file
244
PediFoods/Views/Main/SavedCardsView.swift
Normal file
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SavedCardsView: View {
|
||||
@Binding var appState: AppState
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var cards: [SavedCard] = []
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String? = nil
|
||||
@State private var openSwipeRowId: String? = nil
|
||||
@State private var deletingCardId: String? = nil
|
||||
@State private var showAddCard = false
|
||||
|
||||
private var canDelete: Bool { cards.count > 1 }
|
||||
private let tabBarClearance: CGFloat = 96
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppColors.backgroundLight.ignoresSafeArea()
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 20) {
|
||||
screenHeader
|
||||
|
||||
VStack(spacing: 12) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 32)
|
||||
} else if let errorMessage {
|
||||
Text(errorMessage)
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 32)
|
||||
} else if cards.isEmpty {
|
||||
Text("Nenhum cartão cadastrado")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.top, 32)
|
||||
} else {
|
||||
ForEach(cards) { card in
|
||||
if canDelete {
|
||||
SwipeToDeleteAddressRow(
|
||||
rowId: card.id,
|
||||
openRowId: $openSwipeRowId,
|
||||
isDeleting: deletingCardId == card.id,
|
||||
onDelete: { deleteCard(card) }
|
||||
) {
|
||||
cardRow(card)
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if openSwipeRowId == card.id { openSwipeRowId = nil }
|
||||
}
|
||||
}
|
||||
.id(card.id)
|
||||
.opacity(deletingCardId == card.id ? 0.6 : 1.0)
|
||||
.disabled(deletingCardId != nil)
|
||||
} else {
|
||||
cardRow(card)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, tabBarClearance + 20)
|
||||
}
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
addCardButton
|
||||
.padding(.bottom, tabBarClearance)
|
||||
}
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.appHiddenNavigationBar()
|
||||
.sheet(isPresented: $showAddCard) {
|
||||
NavigationStack {
|
||||
AddCardFormView(appState: appState, isFirstCard: cards.isEmpty) { newCard in
|
||||
cards.append(newCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadCards() }
|
||||
}
|
||||
|
||||
private var screenHeader: some View {
|
||||
ZStack {
|
||||
Text("Meus Cartões")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
HStack {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(Circle())
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var addCardButton: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(height: 136)
|
||||
Button(action: { showAddCard = true }) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "creditcard.fill")
|
||||
.font(.system(size: 20))
|
||||
Text("Adicionar novo cartão")
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
}
|
||||
|
||||
private func cardRow(_ card: SavedCard) -> some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(AppColors.backgroundLight)
|
||||
.frame(width: 52, height: 52)
|
||||
if let logo = brandLogoName(for: card.brand) {
|
||||
Image(logo)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 36, height: 24)
|
||||
} else {
|
||||
Image(systemName: "creditcard.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(card.displayLabel)
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
if card.isDefault {
|
||||
Text("Principal")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(AppColors.brandSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
Text("Vence \(card.expiryLabel)")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
if !canDelete {
|
||||
Text("Ao menos um cartão deve permanecer")
|
||||
.font(AppTypography.caption)
|
||||
.foregroundStyle(AppColors.textMuted.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||
}
|
||||
|
||||
private func brandLogoName(for brand: String?) -> String? {
|
||||
switch brand?.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.folding(options: .diacriticInsensitive, locale: .current) {
|
||||
case "visa": return "visacard_logo"
|
||||
case "mastercard", "master": return "mastercard_logo"
|
||||
case "amex", "american express", "americanexpress": return "amexcard_logo"
|
||||
case "hipercard": return "hipercard_logo"
|
||||
case "alelo": return "alelocard_logo"
|
||||
case "sodexo": return "sodexo_logo"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func loadCards() async {
|
||||
guard isLoading == false else { return }
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let response = try await ApiService().listCards()
|
||||
if response.error == false {
|
||||
cards = response.result ?? []
|
||||
} else {
|
||||
errorMessage = response.message ?? "Erro ao carregar cartões."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Não foi possível carregar seus cartões."
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteCard(_ card: SavedCard) {
|
||||
guard canDelete, deletingCardId == nil else { return }
|
||||
deletingCardId = card.id
|
||||
openSwipeRowId = nil
|
||||
Task {
|
||||
do {
|
||||
let response = try await ApiService().deleteCard(cardId: card.id)
|
||||
if response.error == false {
|
||||
cards.removeAll { $0.id == card.id }
|
||||
} else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível excluir o cartão.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Erro ao excluir cartão.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
deletingCardId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user