segregate files

This commit is contained in:
Daniel Arantes Loverde
2026-02-13 11:01:54 -03:00
parent 4a40936293
commit d7b8b1864a
20 changed files with 2653 additions and 1923 deletions

View File

@@ -91,7 +91,8 @@ struct HomeView: View {
storeLogoURL: store.logoURL,
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance
storeDistance: store.distance,
appState: $appState
)
} label: {
FeaturedStoreCard(store: store)
@@ -159,7 +160,8 @@ struct HomeView: View {
storeLogoURL: store.logoURL,
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance
storeDistance: store.distance,
appState: $appState
)
} label: {
FeaturedStoreCard(store: store)
@@ -410,127 +412,6 @@ struct HomeView: View {
}
}
private func buildCategories(from stores: [StoreSummary]) -> [CategoryModel] {
var unique: [CategoryModel] = [
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
var seen = Set<String>()
for store in stores {
let raw = (store.category ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { continue }
let dedupe = raw.lowercased()
if seen.contains(dedupe) { continue }
seen.insert(dedupe)
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" }
if value.contains("lanche") || value.contains("hamburg") || value.contains("burger") { return "fork.knife" }
if value.contains("cafe") || value.contains("breakfast") { return "sun.max" }
if value.contains("doce") || value.contains("sobremesa") || value.contains("dessert") { return "cup.and.saucer" }
return "storefront"
}
@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."
}
@ViewBuilder
private var scrollOffsetObserver: some View {
@@ -547,136 +428,3 @@ struct HomeView: View {
#endif
}
}
struct CategoryModel: Identifiable {
let id: String
let title: String
let systemIcon: String?
let emojiIcon: String?
}
struct CategoryChip: View {
let title: String
let systemIcon: String?
let emojiIcon: String?
let isActive: Bool
var body: some View {
HStack(spacing: 8) {
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)
}
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isActive ? AppColors.brandDark : AppColors.surface)
.clipShape(Capsule())
}
}
struct SearchBar: View {
let placeholder: String
@Binding var text: String
var onFilterTap: () -> Void = {}
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: $text)
.appNoAutoCap()
Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted)
}
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}
#if os(iOS)
@MainActor
private struct ScrollOffsetObserver: UIViewRepresentable {
let onOffsetChange: (CGFloat) -> Void
func makeUIView(context: Context) -> ScrollOffsetProbeView {
let view = ScrollOffsetProbeView()
view.onOffsetChange = onOffsetChange
return view
}
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
uiView.onOffsetChange = onOffsetChange
uiView.attachIfNeeded()
}
}
@MainActor
private final class ScrollOffsetProbeView: UIView {
var onOffsetChange: (CGFloat) -> Void = { _ in }
private weak var observedScrollView: UIScrollView?
private var observation: NSKeyValueObservation?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
deinit {
observation?.invalidate()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
attachIfNeeded()
}
override func didMoveToWindow() {
super.didMoveToWindow()
attachIfNeeded()
}
func attachIfNeeded() {
guard let scrollView = findEnclosingScrollView() else { return }
guard scrollView !== observedScrollView else { return }
observation?.invalidate()
observedScrollView = scrollView
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
self?.onOffsetChange(sv.contentOffset.y)
}
}
private func findEnclosingScrollView() -> UIScrollView? {
var view: UIView? = self
while let current = view {
if let scrollView = current as? UIScrollView {
return scrollView
}
view = current.superview
}
return nil
}
}
#endif