Not has address
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user