login flow

This commit is contained in:
Daniel Arantes Loverde
2026-02-12 09:44:40 -03:00
parent 3176b67914
commit 38fc71718b
12 changed files with 710 additions and 111 deletions

View File

@@ -1,6 +1,7 @@
import Foundation
import SwiftUI
#if os(iOS)
import LCEssentials
import UIKit
#endif
@@ -9,7 +10,7 @@ struct HomeView: View {
@State var searchText = ""
@State var selectedCategory = "all"
@State var categories: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle")
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
@State var scrollOffset: CGFloat = 0
@State var collapseBaseOffset: CGFloat = 0
@@ -120,7 +121,8 @@ struct HomeView: View {
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
category: selectedCategory == "all" ? nil : selectedCategory
category: selectedCategory == "all" ? nil : selectedCategory,
refreshCategories: true
)
}
}
@@ -166,13 +168,15 @@ struct HomeView: View {
.foregroundStyle(AppColors.brandDark)
)
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .center, spacing: 4) {
Text("DELIVERY LOCATION")
.font(AppTypography.overline)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(AppColors.brandSoft)
.multilineTextAlignment(.center)
Button {
appState.address.onboardingMessage = nil
appState.activeModal = .addressPicker
} label: {
HStack(spacing: 6) {
@@ -186,8 +190,7 @@ struct HomeView: View {
}
.buttonStyle(.plain)
}
Spacer()
.frame(maxWidth: .infinity, alignment: .center)
Circle()
.fill(Color.white.opacity(0.18))
@@ -199,13 +202,21 @@ struct HomeView: View {
}
.opacity(topRowOpacity)
.offset(y: collapseProgress * -12)
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
if UIDevice().modelName.lowercased().contains("se") || UIDevice().modelName.lowercased().contains("16e") {
Text("O que vai querer \npedir hoje?")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
} else {
Text("O que vai querer pedir hoje?\n ")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
}
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
appState.activeModal = .filters
}
@@ -240,6 +251,7 @@ struct HomeView: View {
CategoryChip(
title: category.title,
systemIcon: category.systemIcon,
emojiIcon: category.emojiIcon,
isActive: category.id == selectedCategory
)
.onTapGesture {
@@ -293,10 +305,27 @@ struct HomeView: View {
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),
iconName: "storefront"
iconName: "storefront",
imageURL: resolveStoreImageURL(logo: store.logo, cover: store.cover)
)
}
private func resolveStoreImageURL(logo: String?, cover: String?) -> String? {
let preferred = [logo, cover]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { $0.isEmpty == false }
guard let raw = preferred else { return nil }
if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
return raw
}
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
return "\(base)\(path)"
}
private func formatDistance(_ distance: Double?) -> String {
guard let distance else { return "Distância indisponível" }
if distance >= 1 {
@@ -314,7 +343,10 @@ struct HomeView: View {
isLoadingStores = true
storesError = nil
guard let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) else {
let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh)
let hasAddress = hasConfiguredAddress()
if coordinate == nil && hasAddress == false {
isLoadingStores = false
stores = []
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
@@ -324,7 +356,11 @@ struct HomeView: View {
}
do {
let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1, category: category)
let response = try await ApiService().listStores(
lat: coordinate?.0,
lng: coordinate?.1,
category: category
)
isLoadingStores = false
if response.error {
stores = []
@@ -333,8 +369,8 @@ struct HomeView: View {
}
let results = response.result ?? []
stores = results
if refreshCategories {
categories = buildCategories(from: results)
if refreshCategories || (category == nil && categories.count <= 1) {
await loadHomeCategories(withFallbackStores: results)
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
@@ -349,7 +385,7 @@ struct HomeView: View {
private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
var unique: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle")
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
var seen = Set<String>()
@@ -359,11 +395,59 @@ struct HomeView: View {
let dedupe = raw.lowercased()
if seen.contains(dedupe) { continue }
seen.insert(dedupe)
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw)))
unique.append(.init(id: raw, title: raw, systemIcon: categoryIcon(for: raw), emojiIcon: nil))
}
return unique
}
@MainActor
private func loadHomeCategories(withFallbackStores stores: [StoreSummary]) async {
do {
let response = try await ApiService().listPublicCategories()
if response.error == false, let remote = response.result, remote.isEmpty == false {
categories = mapPublicCategories(remote)
return
}
} catch {
// Fallback handled below.
}
categories = buildCategories(from: stores)
}
private func mapPublicCategories(_ remote: [PublicCategory]) -> [CategoryModel] {
var mapped: [CategoryModel] = []
var seen = Set<String>()
for item in remote {
let id = item.id.trimmingCharacters(in: .whitespacesAndNewlines)
let title = item.name.trimmingCharacters(in: .whitespacesAndNewlines)
if id.isEmpty || title.isEmpty { continue }
if seen.contains(id.lowercased()) { continue }
seen.insert(id.lowercased())
mapped.append(
.init(
id: id,
title: title,
systemIcon: id.lowercased() == "all" ? "line.3.horizontal.decrease.circle" : nil,
emojiIcon: item.icon
)
)
}
if mapped.contains(where: { $0.id.lowercased() == "all" }) == false {
mapped.insert(.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil), at: 0)
} else {
mapped.sort { lhs, rhs in
if lhs.id.lowercased() == "all" { return true }
if rhs.id.lowercased() == "all" { return false }
return lhs.title < rhs.title
}
}
return mapped
}
private func categoryIcon(for category: String) -> String {
let value = category.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current).lowercased()
if value.contains("pizza") { return "takeoutbag.and.cup.and.straw" }
@@ -440,18 +524,25 @@ struct HomeView: View {
struct CategoryModel: Identifiable {
let id: String
let title: String
let systemIcon: String
let systemIcon: String?
let emojiIcon: String?
}
struct CategoryChip: View {
let title: String
let systemIcon: String
let systemIcon: String?
let emojiIcon: String?
let isActive: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: systemIcon)
.font(.caption)
if let emojiIcon, emojiIcon.isEmpty == false {
Text(emojiIcon)
.font(.body)
} else if let systemIcon, systemIcon.isEmpty == false {
Image(systemName: systemIcon)
.font(.caption)
}
Text(title)
.font(AppTypography.heading3)
}