Not has address

This commit is contained in:
Daniel Arantes Loverde
2026-02-07 17:16:45 -03:00
parent 92c2a67736
commit 54686397b9
28 changed files with 2115 additions and 498 deletions

View File

@@ -9,7 +9,9 @@ struct HomeView: View {
@State var selectedCategory = "Stores"
@State var scrollOffset: CGFloat = 0
@State var hasRequestedLocation = false
let locationService = LocationService()
@State var isLoadingStores = false
@State var storesError: String? = nil
@State var stores: [StoreSummary] = []
private let categories: [CategoryModel] = [
.init(title: "Stores", systemIcon: "storefront"),
@@ -19,52 +21,6 @@ struct HomeView: View {
.init(title: "Dessert", systemIcon: "cup.and.saucer")
]
private let featuredStores: [FeaturedStoreCardModel] = [
.init(
name: "Burger Kingdom",
rating: 4.9,
reviews: "1.2k",
distance: "1.2 km",
category: "Fast Food",
promoText: "10% OFF",
isFavorite: false,
iconName: "takeoutbag.and.cup.and.straw"
),
.init(
name: "Sushi House",
rating: 4.8,
reviews: "840",
distance: "2.2 km",
category: "Japanese",
promoText: nil,
isFavorite: true,
iconName: "fork.knife"
)
]
private let nearbyStores: [FeaturedStoreCardModel] = [
.init(
name: "Pizza Prime",
rating: 4.7,
reviews: "510",
distance: "1.8 km",
category: "Pizza",
promoText: nil,
isFavorite: false,
iconName: "takeoutbag.and.cup.and.straw"
),
.init(
name: "Aoyama",
rating: 4.9,
reviews: "2.4k",
distance: "3.1 km",
category: "Japanese",
promoText: nil,
isFavorite: true,
iconName: "fork.knife"
)
]
private let specials: [SpecialOfferCardModel] = [
.init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
.init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
@@ -95,10 +51,6 @@ struct HomeView: View {
.padding(.top, headerPadding + 16)
.padding(.bottom, 24)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { value in
scrollOffset = min(0, value)
}
#endif
header(collapseProgress: collapseProgress, height: headerHeight)
@@ -110,14 +62,8 @@ struct HomeView: View {
.onAppear {
if hasRequestedLocation == false {
hasRequestedLocation = true
locationService.requestLocation { result in
switch result {
case .success(let coordinate):
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
case .failure:
appState.address.display = "Defina seu endereco"
}
Task {
await bootstrapStoresFlow()
}
}
}
@@ -132,7 +78,7 @@ struct HomeView: View {
section(title: "Featured") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(featuredStores) { store in
ForEach(featuredStoresCards) { store in
FeaturedStoreCard(store: store)
.frame(width: 190)
}
@@ -154,12 +100,41 @@ struct HomeView: View {
}
section(title: "Near you") {
VStack(spacing: 16) {
ForEach(nearbyStores) { store in
FeaturedStoreCard(store: store)
if isLoadingStores {
HStack {
ProgressView()
Text("Buscando estabelecimentos próximos...")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 20)
} else if let storesError {
VStack(alignment: .leading, spacing: 10) {
Text(storesError)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
Button("Tentar novamente") {
Task {
await bootstrapStoresFlow(forceLocationRefresh: true)
}
}
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
} else if nearbyStoreCards.isEmpty {
Text("Nenhum estabelecimento encontrado próximo à sua localização.")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 20)
} else {
VStack(spacing: 16) {
ForEach(nearbyStoreCards) { store in
FeaturedStoreCard(store: store)
}
}
.padding(.horizontal, 20)
}
.padding(.horizontal, 20)
}
}
}
@@ -222,7 +197,6 @@ struct HomeView: View {
Text("O que vai querer pedir hoje?")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textInverse)
.fixedSize(horizontal: false, vertical: true)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
@@ -294,6 +268,120 @@ struct HomeView: View {
.offset(x: 70, y: 10)
}
}
private var sortedStores: [StoreSummary] {
stores.sorted { lhs, rhs in
(lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
}
}
private var featuredStoresCards: [FeaturedStoreCardModel] {
Array(sortedStores.prefix(6)).map(mapStoreToCard)
}
private var nearbyStoreCards: [FeaturedStoreCardModel] {
Array(sortedStores.prefix(20)).map(mapStoreToCard)
}
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
FeaturedStoreCardModel(
name: store.name,
rating: store.rating ?? 0,
reviews: "0",
distance: formatDistance(store.distance),
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),
iconName: "storefront"
)
}
private func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" }
if distance >= 1 {
return String(format: "%.1f km", distance)
}
return "\(Int(distance * 1000)) m"
}
@MainActor
private func bootstrapStoresFlow(forceLocationRefresh: Bool = false) async {
isLoadingStores = true
storesError = nil
guard let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) else {
isLoadingStores = false
stores = []
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
appState.activeModal = .addressPicker
return
}
do {
let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1)
isLoadingStores = false
if response.error {
stores = []
storesError = response.message ?? "Não foi possível carregar os estabelecimentos."
return
}
stores = response.result ?? []
storesError = nil
} catch {
isLoadingStores = false
stores = []
storesError = storesUserMessage(error)
}
}
@MainActor
private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
if !forceRefresh {
if let lat = appState.address.latitude, let lng = appState.address.longitude {
return (lat, lng)
}
if let cached = LocationService.shared.cachedLocation() {
appState.address.latitude = cached.0
appState.address.longitude = cached.1
return cached
}
if hasConfiguredAddress() == false {
return nil
}
}
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
if let coordinate {
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
}
return coordinate
}
private func hasConfiguredAddress() -> Bool {
if appState.address.selectedId != nil {
return true
}
if appState.address.latitude != nil, appState.address.longitude != nil {
return true
}
let normalized = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
return normalized.isEmpty == false && normalized != "defina seu endereco"
}
private func storesUserMessage(_ error: Error) -> String {
if let service = error as? ApiServiceError {
return service.errorDescription ?? "Não foi possível carregar os estabelecimentos."
}
if let network = error as? NetworkError {
return network.errorDescription ?? "Não foi possível carregar os estabelecimentos."
}
return "Não foi possível carregar os estabelecimentos."
}
}
struct CategoryModel: Identifiable {
@@ -332,8 +420,7 @@ struct SearchBar: View {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: $text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.appNoAutoCap()
Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3")

View File

@@ -5,6 +5,8 @@ struct ProfileView: View {
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@Binding var appState: AppState
@State var openAddressesOnboarding = false
@State var onboardingMessage: String? = nil
var body: some View {
VStack(spacing: 20) {
@@ -16,7 +18,7 @@ struct ProfileView: View {
}
NavigationLink("Enderecos") {
Text("Enderecos")
AddressesView(message: nil, appState: $appState)
}
NavigationLink("Ajuda") {
@@ -26,6 +28,13 @@ struct ProfileView: View {
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
NavigationLink(isActive: $openAddressesOnboarding) {
AddressesView(message: onboardingMessage, appState: $appState)
} label: {
EmptyView()
}
.hidden()
Spacer()
SecondaryButton(title: "Sair") {
@@ -39,6 +48,14 @@ struct ProfileView: View {
.padding(.top, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
.onAppear {
guard let message = appState.address.onboardingMessage else {
return
}
onboardingMessage = message
appState.address.onboardingMessage = nil
openAddressesOnboarding = true
}
}
private var header: some View {
@@ -63,6 +80,268 @@ struct ProfileView: View {
}
}
struct AddressesView: View {
let message: String?
@Binding var appState: AppState
@Environment(\.dismiss) var dismiss
@State var isLoading = false
@State var errorMessage: String? = nil
@State var addresses: [CustomerAddress] = []
let tabBarClearance: CGFloat = 96
var body: some View {
ZStack {
AppColors.backgroundLight
.ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
header
if let message {
Text(message)
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .center)
.background(AppColors.brandSoft)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
VStack(spacing: 16) {
if isLoading {
ProgressView()
.padding(.top, 24)
} else if let errorMessage {
Text(errorMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.multilineTextAlignment(.center)
.padding(.top, 24)
} else if addresses.isEmpty {
Text("Nenhum endereço cadastrado")
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.top, 24)
} else {
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
AddressCard(item: addressToListItem(address, isPrimary: index == 0))
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
VStack {
Spacer()
bottomOverlay
.padding(.bottom, tabBarClearance)
}
}
.navigationBarBackButtonHidden(true)
.toolbar(.hidden, for: .navigationBar)
.onAppear {
if isLoading == false, addresses.isEmpty {
Task {
await loadAddresses()
}
}
}
}
var header: some View {
ZStack {
Text("Meus Endereços")
.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)
}
Spacer()
}
}
}
var bottomOverlay: some View {
ZStack(alignment: .bottom) {
Rectangle()
.fill(AppColors.backgroundLight)
.frame(height: 136)
Button(action: {}) {
HStack(spacing: 12) {
Image(systemName: "mappin.circle.fill")
.font(.system(size: 24))
Text("Adicionar novo endereç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)
}
}
func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
let line1 = [address.address, address.number]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
.joined(separator: ", ")
let line2 = [address.neighborhood, address.city, address.state]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.isEmpty == false }
.joined(separator: ", ")
let detail = [line1, line2]
.filter { $0.isEmpty == false }
.joined(separator: " - ")
return AddressListItem(
title: title,
detail: detail.isEmpty ? "Endereço sem detalhes" : detail,
icon: iconName(for: title),
isPrimary: isPrimary
)
}
func iconName(for label: String) -> String {
let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
if normalized.contains("casa") {
return "house.fill"
}
if normalized.contains("trabalho") {
return "briefcase.fill"
}
return "mappin.and.ellipse"
}
@MainActor
func loadAddresses() async {
isLoading = true
errorMessage = nil
do {
let service = ApiService()
let response = try await service.profile()
guard response.error == false else {
errorMessage = response.message ?? "Não foi possível carregar os endereços."
isLoading = false
return
}
addresses = response.result?.addressBook ?? []
if let first = addresses.first {
appState.address.selectedId = first.id
appState.address.display = first.label ?? "Defina seu endereco"
if let lat = first.latLong?.first, let lng = first.latLong?.dropFirst().first {
appState.address.latitude = lat
appState.address.longitude = lng
}
}
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
struct AddressCard: View {
let item: AddressListItem
var body: some View {
HStack(spacing: 14) {
icon
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Text(item.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
if item.isPrimary {
Text("PRINCIPAL")
.font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(AppColors.tertiary)
.clipShape(Capsule())
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
Text(item.detail)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(2)
}
Spacer(minLength: 8)
Rectangle()
.fill(AppColors.backgroundLight)
.frame(width: 1, height: 96)
VStack(spacing: 24) {
Button(action: {}) {
Image(systemName: "pencil")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
Button(action: {}) {
Image(systemName: "trash")
.font(.system(size: 22))
.foregroundStyle(AppColors.textMuted)
}
}
.frame(width: 40)
}
.padding(.horizontal, 16)
.padding(.vertical, 20)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
var icon: some View {
Image(systemName: item.icon)
.font(.system(size: 28))
.foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary)
.frame(width: 84, height: 84)
.background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
struct AddressListItem: Identifiable {
let id = UUID()
let title: String
let detail: String
let icon: String
let isPrimary: Bool
}
struct OrdersView: View {
var body: some View {
VStack(spacing: 16) {