migration

This commit is contained in:
Daniel Arantes Loverde
2026-08-11 13:22:02 -03:00
parent c4d6998595
commit 7702836fe7
231 changed files with 4329 additions and 1958 deletions

View 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
}
}