migration
This commit is contained in:
429
PediFoods/Views/Main/ProfileView.swift
Normal file
429
PediFoods/Views/Main/ProfileView.swift
Normal file
@@ -0,0 +1,429 @@
|
||||
import SwiftUI
|
||||
#if canImport(LCEssentials)
|
||||
import LCEssentials
|
||||
#endif
|
||||
import UIKit
|
||||
|
||||
struct ProfileView: View {
|
||||
@Binding var selectedTab: MainTab
|
||||
let tokenStore: TokenStore
|
||||
@Binding var appState: AppState
|
||||
let enterAuth: () -> Void
|
||||
@State var openAddressesOnboarding = false
|
||||
@State var onboardingMessage: String? = nil
|
||||
@State var showLogoutAlert = false
|
||||
@State var showDeleteAccountAlert = false
|
||||
@State var isDeletingAccount = false
|
||||
@State private var openOrders = false
|
||||
let tabBarClearance: CGFloat = 120
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 18) {
|
||||
NavigationLink {
|
||||
UserProfileView(appState: $appState)
|
||||
} label: {
|
||||
header
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
NavigationLink {
|
||||
OrdersView(appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "list.bullet.clipboard.fill", title: "Meus Pedidos")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
AddressesView(message: nil, appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "mappin.circle.fill", title: "Meus Endereços")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
SavedCardsView(appState: $appState)
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "creditcard.fill", title: "Meus Cartões")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
MyReviewsView()
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "star.fill", title: "Minhas Avaliações")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if appState.featureFlags.isEnabled("at.cupons") {
|
||||
NavigationLink {
|
||||
Text("Cupons de Desconto")
|
||||
} label: {
|
||||
ProfileMenuRow(icon: "ticket.fill", title: "Cupons de Desconto", badge: "NOVO")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// NavigationLink {
|
||||
// Text("Ajuda")
|
||||
// } label: {
|
||||
// ProfileMenuRow(icon: "gearshape.fill", title: "Configurações")
|
||||
// }
|
||||
// .buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Button(action: { showLogoutAlert = true }) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("Sair da Conta")
|
||||
.font(AppTypography.heading3)
|
||||
}
|
||||
.foregroundStyle(Color.red)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Button(action: { showDeleteAccountAlert = true }) {
|
||||
HStack(spacing: 10) {
|
||||
if isDeletingAccount {
|
||||
ProgressView()
|
||||
.tint(Color.red)
|
||||
} else {
|
||||
Image(systemName: "trash.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
Text("Excluir Conta")
|
||||
.font(AppTypography.body)
|
||||
}
|
||||
.foregroundStyle(Color.red.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isDeletingAccount)
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
Text("Versão 1.0b")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
NavigationLink {
|
||||
TermsOfUseView()
|
||||
} label: {
|
||||
Text("Termos de Uso")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text("·")
|
||||
|
||||
NavigationLink {
|
||||
PrivacyPolicyView()
|
||||
} label: {
|
||||
Text("Política de Privacidade")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.appBottomSafeAreaInset {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight.opacity(0.8))
|
||||
.frame(height: tabBarClearance)
|
||||
.padding(.bottom, -UIDevice.bottomNotch)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
.alert("Sair da conta?", isPresented: $showLogoutAlert) {
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
Button("Sair", role: .destructive) {
|
||||
logout()
|
||||
}
|
||||
} message: {
|
||||
Text("Tem certeza que deseja sair da sua conta?")
|
||||
}
|
||||
.alert("Excluir sua conta?", isPresented: $showDeleteAccountAlert) {
|
||||
Button("Cancelar", role: .cancel) {}
|
||||
Button("Excluir", role: .destructive) {
|
||||
Task { await deleteAccount() }
|
||||
}
|
||||
} message: {
|
||||
Text("Essa ação é permanente. Seus dados de perfil, endereços, cartões e favoritos serão excluídos e não poderão ser recuperados.")
|
||||
}
|
||||
.onAppear {
|
||||
guard let message = appState.address.onboardingMessage else {
|
||||
return
|
||||
}
|
||||
onboardingMessage = message
|
||||
appState.address.onboardingMessage = nil
|
||||
openAddressesOnboarding = true
|
||||
}
|
||||
.sheet(isPresented: $openAddressesOnboarding) {
|
||||
NavigationStack {
|
||||
AddressesView(message: onboardingMessage, appState: $appState)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $openOrders) {
|
||||
OrdersView(appState: $appState)
|
||||
}
|
||||
.onChange(of: appState.shouldNavigateToOrders) { _, val in
|
||||
if val {
|
||||
appState.shouldNavigateToOrders = false
|
||||
openOrders = true
|
||||
}
|
||||
}
|
||||
.onChange(of: appState.pendingOrderDeepLink) { _, val in
|
||||
if val != nil {
|
||||
openOrders = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: 10) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.18))
|
||||
.frame(width: 96, height: 96)
|
||||
.overlay(
|
||||
Group {
|
||||
if let picture = profilePictureURL {
|
||||
AsyncStoreImage(imageURL: picture)
|
||||
.frame(width: 92, height: 92)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Text(profileInitials)
|
||||
.font(.system(size: 30, weight: .bold))
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(AppColors.tertiary)
|
||||
.frame(width: 36, height: 36)
|
||||
.overlay(
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.black.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
Text(profileName)
|
||||
.font(.system(size: 22, weight: .heavy))
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Ver Perfil")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(AppColors.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 54)
|
||||
.padding(.bottom, 32)
|
||||
.background(headerGradient)
|
||||
.clipShape(
|
||||
ProfileHeaderShape(
|
||||
topLeadingRadius: 0,
|
||||
bottomLeadingRadius: 42,
|
||||
bottomTrailingRadius: 42,
|
||||
topTrailingRadius: 0
|
||||
)
|
||||
)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
|
||||
private var headerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [Color(hex: "#123221"), Color(hex: "#0F2A1C")],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
|
||||
private var profileName: String {
|
||||
let trimmed = appState.profile.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Alex Silva" : trimmed
|
||||
}
|
||||
|
||||
private var profileInitials: String {
|
||||
let parts = profileName.split(separator: " ").prefix(2)
|
||||
let joined = parts.compactMap { $0.first }.map(String.init).joined()
|
||||
return joined.isEmpty ? "AS" : joined.uppercased()
|
||||
}
|
||||
|
||||
private var profilePictureURL: String? {
|
||||
let raw = appState.profile.profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard raw.isEmpty == false else { return nil }
|
||||
return ImageSourceResolver.resolve(raw)
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
tokenStore.clear()
|
||||
SessionStateStore.clearActiveUser()
|
||||
SessionStateStore.clearTrackedOrders()
|
||||
AppContentCache.shared.invalidate()
|
||||
AppImageCache.shared.invalidateAll()
|
||||
appState = AppState()
|
||||
enterAuth()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deleteAccount() async {
|
||||
guard isDeletingAccount == false else { return }
|
||||
isDeletingAccount = true
|
||||
defer { isDeletingAccount = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().deleteAccount()
|
||||
guard response.error == false else {
|
||||
SnackbarCenter.shared.show(
|
||||
title: response.message ?? "Não foi possível excluir sua conta.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.5
|
||||
)
|
||||
return
|
||||
}
|
||||
logout()
|
||||
} catch {
|
||||
SnackbarCenter.shared.show(
|
||||
title: "Não foi possível excluir sua conta. Tente novamente.",
|
||||
style: .error,
|
||||
icon: "xmark.octagon.fill",
|
||||
duration: 3.5
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileLoggedOutView: View {
|
||||
let enterAuth: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||
.font(.system(size: 56, weight: .regular))
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
Text("Entre na sua conta")
|
||||
.font(AppTypography.heading2)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
Text("Faça login ou cadastre-se para ver seu perfil, pedidos e endereços.")
|
||||
.font(AppTypography.body)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Button {
|
||||
enterAuth()
|
||||
} label: {
|
||||
Text("Entrar ou Cadastrar")
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textInverse)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(AppColors.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(AppColors.backgroundLight)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileMenuRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(Color(hex: "#E9F0E2"))
|
||||
.frame(width: 44, height: 44)
|
||||
.overlay(
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "#173824"))
|
||||
)
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "#0F1A34"))
|
||||
|
||||
Spacer(minLength: 10)
|
||||
|
||||
if let badge {
|
||||
Text(badge)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(Color(hex: "#1C2A1C"))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(hex: "#EAF1D6"))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: "#BFC7D4"))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
.background(AppColors.surface)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.shadow(color: Color.black.opacity(0.02), radius: 6, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileHeaderShape: Shape {
|
||||
var topLeadingRadius: CGFloat
|
||||
var bottomLeadingRadius: CGFloat
|
||||
var bottomTrailingRadius: CGFloat
|
||||
var topTrailingRadius: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let tl = min(min(topLeadingRadius, rect.width / 2), rect.height / 2)
|
||||
let tr = min(min(topTrailingRadius, rect.width / 2), rect.height / 2)
|
||||
let bl = min(min(bottomLeadingRadius, rect.width / 2), rect.height / 2)
|
||||
let br = min(min(bottomTrailingRadius, rect.width / 2), rect.height / 2)
|
||||
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX + tl, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX - tr, y: rect.minY))
|
||||
path.addArc(center: CGPoint(x: rect.maxX - tr, y: rect.minY + tr), radius: tr, startAngle: .degrees(-90), endAngle: .degrees(0), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - br))
|
||||
path.addArc(center: CGPoint(x: rect.maxX - br, y: rect.maxY - br), radius: br, startAngle: .degrees(0), endAngle: .degrees(90), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.minX + bl, y: rect.maxY))
|
||||
path.addArc(center: CGPoint(x: rect.minX + bl, y: rect.maxY - bl), radius: bl, startAngle: .degrees(90), endAngle: .degrees(180), clockwise: false)
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + tl))
|
||||
path.addArc(center: CGPoint(x: rect.minX + tl, y: rect.minY + tl), radius: tl, startAngle: .degrees(180), endAngle: .degrees(270), clockwise: false)
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user