diff --git a/PediFoods.code-workspace b/PediFoods.code-workspace
new file mode 100644
index 0000000..876a149
--- /dev/null
+++ b/PediFoods.code-workspace
@@ -0,0 +1,8 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/pedi-foods/Darwin/Info.plist b/pedi-foods/Darwin/Info.plist
index 6d5f8f6..0c67376 100644
--- a/pedi-foods/Darwin/Info.plist
+++ b/pedi-foods/Darwin/Info.plist
@@ -1,8 +1,5 @@
-
- ITSAppUsesNonExemptEncryption
-
-
+
diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj
index c2e96d7..3edd5e9 100644
--- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj
+++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj
@@ -12,8 +12,8 @@
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
- B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; };
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; };
+ B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -42,7 +42,7 @@
49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; };
49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = ""; };
- B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
+ B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -92,7 +92,7 @@
isa = PBXGroup;
children = (
49F90C2B2A52156200F06D93 /* Main.swift */,
- B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */,
+ B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
49F90C312A52156300F06D93 /* Entitlements.plist */,
4900101C2BACEA710000DE33 /* Info.plist */,
@@ -165,7 +165,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */,
+ B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
);
@@ -214,6 +214,8 @@
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
+ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
};
@@ -226,6 +228,8 @@
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
+ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
};
diff --git a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
index 9cec3ae..8b643b2 100644
--- a/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
+++ b/pedi-foods/Sources/PediFoods/Components/CachedRemoteImage.swift
@@ -11,7 +11,9 @@ struct CachedRemoteImage: View {
let ttl: TimeInterval
let placeholder: Placeholder
+#if canImport(UIKit) || canImport(AppKit)
@StateObject var loader = CachedRemoteImageLoader()
+#endif
init(
imageURL: String?,
@@ -60,14 +62,19 @@ struct CachedRemoteImage: View {
#endif
}
.onAppear {
+#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: imageURL, ttl: ttl)
+#endif
}
.onChange(of: imageURL) { _, newValue in
+#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: newValue, ttl: ttl)
+#endif
}
}
}
+#if canImport(UIKit) || canImport(AppKit)
@MainActor
final class CachedRemoteImageLoader: ObservableObject {
#if canImport(UIKit)
@@ -174,3 +181,4 @@ final class CachedRemoteImageLoader: ObservableObject {
}
#endif
}
+#endif
diff --git a/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift b/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift
index 249048c..98a0bde 100644
--- a/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift
+++ b/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift
@@ -30,7 +30,7 @@ struct SnackbarOverlay: View {
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor)
- .contentShape(Rectangle())
+ .appContentShape(Rectangle())
.onTapGesture {
center.handleTap()
}
diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift
index 318683c..fe2173c 100644
--- a/pedi-foods/Sources/PediFoods/ContentView.swift
+++ b/pedi-foods/Sources/PediFoods/ContentView.swift
@@ -14,6 +14,7 @@ struct ContentView: View {
#if os(iOS)
@State private var sessionExpiredObserver: NSObjectProtocol?
#endif
+ @State var cartResetObserver: Any?
#if os(Android)
@State var snackbarCenter = SnackbarCenter.shared
#else
@@ -54,7 +55,7 @@ struct ContentView: View {
case .addressPicker:
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
case .filters:
- FiltersModalView()
+ FiltersModalView(appState: $appState)
}
}
.onChange(of: appState.address.display) { _, _ in
@@ -69,9 +70,6 @@ struct ContentView: View {
.onChange(of: appState.address.longitude) { _, _ in
dismissAddressPickerIfAddressExists()
}
- .onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
- appState.cart = CartState()
- }
.task {
await performInitialLaunchBootstrap()
}
@@ -83,11 +81,13 @@ struct ContentView: View {
}
}
.onAppear {
+ attachCartResetObserverIfNeeded()
#if os(iOS)
attachSessionExpiredObserverIfNeeded()
#endif
}
.onDisappear {
+ detachCartResetObserver()
#if os(iOS)
detachSessionExpiredObserver()
#endif
@@ -182,6 +182,23 @@ struct ContentView: View {
isBootstrappingSession = false
}
+ private func attachCartResetObserverIfNeeded() {
+ guard cartResetObserver == nil else { return }
+ cartResetObserver = NotificationCenter.default.addObserver(
+ forName: .cartDidReset,
+ object: nil,
+ queue: nil
+ ) { _ in
+ appState.cart = CartState()
+ }
+ }
+
+ private func detachCartResetObserver() {
+ guard let cartResetObserver else { return }
+ NotificationCenter.default.removeObserver(cartResetObserver)
+ self.cartResetObserver = nil
+ }
+
@MainActor
private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id
@@ -346,21 +363,3 @@ struct AddressPickerModalView: View {
}
}
}
-
-struct FiltersModalView: View {
- var body: some View {
- NavigationStack {
- VStack(spacing: 12) {
- Text("Filtros")
- .font(AppTypography.heading2)
- Text("Filtros de busca serao ligados na integracao real da Home com API.")
- .font(AppTypography.body)
- .multilineTextAlignment(.center)
- .foregroundStyle(AppColors.textMuted)
- }
- .padding(24)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(AppColors.backgroundLight)
- }
- }
-}
diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings
index 891c3f9..2a72535 100644
--- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings
+++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings
@@ -83,6 +83,10 @@
"comment" : "A heading for the size selection step in the pizza product detail sheet.",
"isCommentAutoGenerated" : true
},
+ "1km" : {
+ "comment" : "A label for the minimum distance filter option.",
+ "isCommentAutoGenerated" : true
+ },
"2. Massa" : {
},
@@ -101,6 +105,10 @@
}
}
},
+ "10km" : {
+ "comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
+ "isCommentAutoGenerated" : true
+ },
"Acompanhamento em tempo real" : {
},
@@ -178,6 +186,10 @@
"comment" : "A button that applies a coupon code to a cart.",
"isCommentAutoGenerated" : true
},
+ "Aplicar Filtros" : {
+ "comment" : "A button label that translates to \"Apply Filters\".",
+ "isCommentAutoGenerated" : true
+ },
"Appearance" : {
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
"extractionState" : "stale",
@@ -208,6 +220,10 @@
}
}
},
+ "Até %lldkm" : {
+ "comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
+ "isCommentAutoGenerated" : true
+ },
"Atualizando status do pedido..." : {
},
@@ -290,6 +306,10 @@
"comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true
},
+ "Categorias" : {
+ "comment" : "A heading for the list of available categories in the filters modal.",
+ "isCommentAutoGenerated" : true
+ },
"Categories" : {
"comment" : "A section header that lists available food categories.",
"isCommentAutoGenerated" : true
@@ -414,10 +434,6 @@
}
}
},
- "DELIVERY LOCATION" : {
- "comment" : "A label describing the delivery location section.",
- "isCommentAutoGenerated" : true
- },
"Desconto" : {
"comment" : "A label for a discount applied to an order.",
"isCommentAutoGenerated" : true
@@ -428,6 +444,9 @@
},
"Detalhes do Pedido" : {
+ },
+ "Distância" : {
+
},
"Do que você gostou?" : {
"comment" : "A prompt asking users to mention what they liked about a review.",
@@ -452,12 +471,15 @@
"comment" : "A text describing delivery mode.",
"isCommentAutoGenerated" : true
},
+ "ENTREGAR EM:" : {
+ "comment" : "A label displayed next to the button that lets the user select an address.",
+ "isCommentAutoGenerated" : true
+ },
"Enviar Avaliação" : {
},
- "Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
- "comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
- "isCommentAutoGenerated" : true
+ "Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso." : {
+
},
"Escaneie o QR Code abaixo ou copie o código para pagar no seu aplicativo do banco." : {
"comment" : "A description under the QR code that instructs the user on how to pay using their bank app.",
@@ -517,10 +539,6 @@
"comment" : "A label for the filter options in the modal.",
"isCommentAutoGenerated" : true
},
- "Filtros de busca serao ligados na integracao real da Home com API." : {
- "comment" : "A description of the filters feature that will be added to the home screen.",
- "isCommentAutoGenerated" : true
- },
"Finalizar Pedido" : {
"comment" : "The title of the checkout screen.",
"isCommentAutoGenerated" : true
@@ -637,6 +655,10 @@
}
}
},
+ "Limpar" : {
+ "comment" : "A button that clears all filters when pressed.",
+ "isCommentAutoGenerated" : true
+ },
"Limpar carrinho e adicionar" : {
"comment" : "A button that clears the current cart and adds the selected product.",
"isCommentAutoGenerated" : true
@@ -719,10 +741,6 @@
"comment" : "A message displayed when a user has no addresses saved.",
"isCommentAutoGenerated" : true
},
- "Nenhum estabelecimento encontrado próximo à sua localização." : {
- "comment" : "A message displayed when no nearby stores are found.",
- "isCommentAutoGenerated" : true
- },
"Nenhum pedido encontrado." : {
},
@@ -795,6 +813,10 @@
"comment" : "A label indicating a required field.",
"isCommentAutoGenerated" : true
},
+ "Ordenar por" : {
+ "comment" : "A label describing the sorting options available in the filters modal.",
+ "isCommentAutoGenerated" : true
+ },
"Pagamento" : {
"comment" : "The title of the screen where a user can enter and save payment information.",
"isCommentAutoGenerated" : true
@@ -803,10 +825,6 @@
"comment" : "The navigation title for the PaymentPixView.",
"isCommentAutoGenerated" : true
},
- "para %@" : {
- "comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.",
- "isCommentAutoGenerated" : true
- },
"Pedido #%@" : {
},
@@ -875,6 +893,10 @@
},
"Precisa de ajuda com esse pedido?" : {
+ },
+ "Preço" : {
+ "comment" : "A label describing the price filter section in the filters modal.",
+ "isCommentAutoGenerated" : true
},
"Preencha os dados abaixo para adicionar um endereço." : {
"comment" : "A description below the form to add a new address, instructing the user to fill in the required information.",
@@ -1161,6 +1183,10 @@
"comment" : "A button label that translates to \"View Profile\" in English.",
"isCommentAutoGenerated" : true
},
+ "Ver todas" : {
+ "comment" : "A link that allows users to see all available categories in the filters.",
+ "isCommentAutoGenerated" : true
+ },
"Verificação" : {
"comment" : "A title for the OTP verification screen.",
"isCommentAutoGenerated" : true
diff --git a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift
index c3f3843..8b54b96 100644
--- a/pedi-foods/Sources/PediFoods/Services/ApiModels.swift
+++ b/pedi-foods/Sources/PediFoods/Services/ApiModels.swift
@@ -115,6 +115,8 @@ struct StoreInfoResult: Decodable {
let isOpen: Bool?
let statusLabel: String?
let fantasyName: String?
+ let phone: String?
+ let whatsapp: String?
let logo: String?
let cover: String?
let deliveryTime: String?
@@ -126,6 +128,8 @@ struct StoreInfoResult: Decodable {
case isOpen
case statusLabel
case fantasyName
+ case phone
+ case whatsapp
case logo
case cover
case deliveryTime
@@ -139,6 +143,8 @@ struct StoreInfoResult: Decodable {
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
fantasyName = try? container.decode(String.self, forKey: .fantasyName)
+ phone = ApiService.decodeFlexibleString(from: container, keys: [.phone])
+ whatsapp = ApiService.decodeFlexibleString(from: container, keys: [.whatsapp, .phone])
logo = try? container.decode(String.self, forKey: .logo)
cover = try? container.decode(String.self, forKey: .cover)
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
diff --git a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift
index f72d51d..d60ad5c 100644
--- a/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift
+++ b/pedi-foods/Sources/PediFoods/Services/ApiOrderTrackingModels.swift
@@ -15,6 +15,7 @@ struct AppOrderSummary: Decodable, Identifiable {
let paymentMethod: String?
let deliveryType: String?
let storeName: String?
+ let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
@@ -35,6 +36,7 @@ struct AppOrderSummary: Decodable, Identifiable {
case paymentMethod
case deliveryType
case storeName
+ case storePhone
case storeLogo
case store_logo
case logo
@@ -59,6 +61,7 @@ struct AppOrderSummary: Decodable, Identifiable {
paymentMethod = ApiService.decodeFlexibleString(from: container, keys: [.paymentMethod])
deliveryType = ApiService.decodeFlexibleString(from: container, keys: [.deliveryType])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
+ storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
let fallbackDate = ApiService.decodeFlexibleString(from: container, keys: [.date])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt]) ?? fallbackDate
@@ -86,6 +89,7 @@ struct PublicOrderResult: Codable, Identifiable {
let discount: Double?
let total: Double?
let storeName: String?
+ let storePhone: String?
let storeLogoURL: String?
let createdAt: String?
let updatedAt: String?
@@ -125,6 +129,7 @@ struct PublicOrderResult: Codable, Identifiable {
case couponDiscount
case total
case storeName
+ case storePhone
case storeLogo
case store_logo
case logo
@@ -165,6 +170,7 @@ struct PublicOrderResult: Codable, Identifiable {
discount: Double? = nil,
total: Double? = nil,
storeName: String? = nil,
+ storePhone: String? = nil,
storeLogoURL: String? = nil,
createdAt: String? = nil,
updatedAt: String? = nil,
@@ -196,6 +202,7 @@ struct PublicOrderResult: Codable, Identifiable {
self.discount = discount
self.total = total
self.storeName = storeName
+ self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -240,6 +247,7 @@ struct PublicOrderResult: Codable, Identifiable {
discount = ApiService.decodeFlexibleDouble(from: container, keys: [.discount, .desconto, .couponDiscount])
total = ApiService.decodeFlexibleDouble(from: container, keys: [.total])
storeName = ApiService.decodeFlexibleString(from: container, keys: [.storeName])
+ storePhone = ApiService.decodeFlexibleString(from: container, keys: [.storePhone])
storeLogoURL = ApiService.decodeFlexibleString(from: container, keys: [.storeLogo, .store_logo, .logo])
createdAt = ApiService.decodeFlexibleString(from: container, keys: [.createdAt])
updatedAt = ApiService.decodeFlexibleString(from: container, keys: [.updatedAt])
@@ -279,6 +287,7 @@ struct PublicOrderResult: Codable, Identifiable {
try container.encodeIfPresent(discount, forKey: .discount)
try container.encodeIfPresent(total, forKey: .total)
try container.encodeIfPresent(storeName, forKey: .storeName)
+ try container.encodeIfPresent(storePhone, forKey: .storePhone)
try container.encodeIfPresent(createdAt, forKey: .createdAt)
try container.encodeIfPresent(updatedAt, forKey: .updatedAt)
try container.encodeIfPresent(otp, forKey: .otp)
diff --git a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift
index 81548f9..5866add 100644
--- a/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift
+++ b/pedi-foods/Sources/PediFoods/Services/OrderRealtimeTracker.swift
@@ -76,18 +76,18 @@ final class OrderRealtimeTracker {
@discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do {
- logger.debug("OrderTracking poll request orderId=\(orderId, privacy: .public)")
+ logger.debug("OrderTracking poll request orderId=\(orderId)")
let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else {
- logger.error("OrderTracking poll API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
+ logger.error("OrderTracking poll API error orderId=\(orderId) message=\(response.message ?? "unknown")")
return nil
}
clearPendingCartIfNeeded(for: order)
- logger.info("OrderTracking poll success orderId=\(orderId, privacy: .public) status=\((order.status ?? "nil"), privacy: .public) paymentStatus=\((order.paymentStatus ?? "nil"), privacy: .public)")
+ logger.info("OrderTracking poll success orderId=\(orderId) status=\(order.status ?? "nil") paymentStatus=\(order.paymentStatus ?? "nil")")
onOrderUpdated?(order)
return order
} catch {
- logger.error("OrderTracking poll failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
+ logger.error("OrderTracking poll failure orderId=\(orderId) error=\(error.localizedDescription)")
return nil
}
}
diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift
index 1b3de3d..b48f28b 100644
--- a/pedi-foods/Sources/PediFoods/State/AppState.swift
+++ b/pedi-foods/Sources/PediFoods/State/AppState.swift
@@ -6,6 +6,7 @@ struct AppState {
var cart = CartState()
var address = AddressState()
var favorites = FavoritesState()
+ var homeFilters = HomeFiltersState()
var activeModal: AppModal? = nil
}
@@ -41,6 +42,57 @@ struct FavoritesState {
var storeIds: Set = []
}
+enum HomeSortOption: String, CaseIterable, Identifiable {
+ case relevance
+ case rating
+ case deliveryTime
+ case price
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .relevance: return "Relevância"
+ case .rating: return "Avaliação"
+ case .deliveryTime: return "Tempo de entrega"
+ case .price: return "Preço"
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .relevance: return "checkmark.seal.fill"
+ case .rating: return "star.fill"
+ case .deliveryTime: return "clock.fill"
+ case .price: return "dollarsign"
+ }
+ }
+}
+
+enum HomePriceTier: String, CaseIterable, Identifiable {
+ case low = "$"
+ case medium = "$$"
+ case high = "$$$"
+ case veryHigh = "$$$$"
+
+ var id: String { rawValue }
+}
+
+struct HomeFiltersState {
+ var sortOption: HomeSortOption = .relevance
+ var selectedCategories: Set = []
+ var selectedPriceTier: HomePriceTier? = nil
+ var maxDistanceKm: Double = 10
+ var availableCategories: [String] = []
+
+ mutating func reset() {
+ sortOption = .relevance
+ selectedCategories = []
+ selectedPriceTier = nil
+ maxDistanceKm = 10
+ }
+}
+
struct CartState {
var storeId: String? = nil
var storeName: String? = nil
diff --git a/pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift b/pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift
new file mode 100644
index 0000000..092b0b3
--- /dev/null
+++ b/pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift
@@ -0,0 +1,103 @@
+import Foundation
+#if canImport(SwiftUI)
+import SwiftUI
+#endif
+
+#if canImport(CoreGraphics)
+import CoreGraphics
+#endif
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+#if !canImport(UIKit)
+struct UIDevice {
+ static let topNotch = 0.0
+ static let bottomNotch = 0.0
+
+ var modelName: String {
+ "android"
+ }
+}
+#endif
+
+#if !(canImport(LCEssentials) && os(iOS))
+func printLog(title: String, msg: String) {
+ print("[\(title)] \(msg)")
+}
+
+func printError(title: String, msg: String) {
+ print("[\(title)] \(msg)")
+}
+#endif
+
+extension View {
+ @ViewBuilder
+ func appInlineNavigationTitle() -> some View {
+#if os(macOS)
+ self
+#else
+ self.navigationBarTitleDisplayMode(.inline)
+#endif
+ }
+
+ @ViewBuilder
+ func appHiddenNavigationBar() -> some View {
+#if os(macOS)
+ self
+#else
+ self
+ .toolbar(.hidden, for: .navigationBar)
+ .toolbarBackground(.hidden, for: .navigationBar)
+#endif
+ }
+
+ @ViewBuilder
+ func appTopBarTrailingToolbar(@ViewBuilder content: () -> Content) -> some View {
+#if os(macOS)
+ self.toolbar {
+ ToolbarItem {
+ content()
+ }
+ }
+#else
+ self.toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ content()
+ }
+ }
+#endif
+ }
+
+ @ViewBuilder
+ func appContentShape(_ shape: S) -> some View {
+#if os(Android)
+ self
+#else
+ self.contentShape(shape)
+#endif
+ }
+
+ @ViewBuilder
+ func appBottomSafeAreaInset(@ViewBuilder content: () -> Content) -> some View {
+#if os(Android)
+ self.overlay(alignment: .bottom) {
+ content()
+ }
+#else
+ self.safeAreaInset(edge: .bottom) {
+ content()
+ }
+#endif
+ }
+
+ @ViewBuilder
+ func appLayoutPriority(_ value: Double) -> some View {
+#if os(Android)
+ self
+#else
+ self.layoutPriority(value)
+#endif
+ }
+}
diff --git a/pedi-foods/Sources/PediFoods/ViewModel.swift b/pedi-foods/Sources/PediFoods/ViewModel.swift
deleted file mode 100644
index bc213ea..0000000
--- a/pedi-foods/Sources/PediFoods/ViewModel.swift
+++ /dev/null
@@ -1,93 +0,0 @@
-import Foundation
-import Observation
-import SkipFuse
-
-/// The Observable ViewModel used by the application.
-@Observable public class ViewModel {
- var items: [Item] = loadItems() {
- didSet { saveItems() }
- }
-
- init() {
- }
-
- func clear() {
- items.removeAll()
- }
-
- func isUpdated(_ item: Item) -> Bool {
- item != items.first { i in
- i.id == item.id
- }
- }
-
- func save(item: Item) {
- items = items.map { i in
- i.id == item.id ? item : i
- }
- }
-}
-
-/// An individual item held by the ViewModel
-struct Item : Identifiable, Hashable, Codable {
- let id: UUID
- var date: Date
- var favorite: Bool
- var title: String
- var notes: String
-
- init(id: UUID = UUID(), date: Date = .now, favorite: Bool = false, title: String = "", notes: String = "") {
- self.id = id
- self.date = date
- self.favorite = favorite
- self.title = title
- self.notes = notes
- }
-
- var itemTitle: String {
- !title.isEmpty ? title : dateString
- }
-
- var dateString: String {
- date.formatted(date: .complete, time: .omitted)
- }
-
- var dateTimeString: String {
- date.formatted(date: .abbreviated, time: .shortened)
- }
-}
-
-/// Utilities for defaulting and persising the items in the list
-extension ViewModel {
- private static let savePath = URL.applicationSupportDirectory.appendingPathComponent("appdata.json")
-
- fileprivate static func loadItems() -> [Item] {
- do {
- let start = Date.now
- let data = try Data(contentsOf: savePath)
- defer {
- let end = Date.now
- logger.info("loaded \(data.count) bytes from \(Self.savePath.path) in \(end.timeIntervalSince(start)) seconds")
- }
- return try JSONDecoder().decode([Item].self, from: data)
- } catch {
- // perhaps the first launch, or the data could not be read
- logger.warning("failed to load data from \(Self.savePath), using defaultItems: \(error)")
- let defaultItems = (1...365).map { Date(timeIntervalSinceNow: Double($0 * 60 * 60 * 24 * -1)) }
- return defaultItems.map({ Item(date: $0) })
- }
- }
-
- fileprivate func saveItems() {
- do {
- let start = Date.now
- let data = try JSONEncoder().encode(items)
- try FileManager.default.createDirectory(at: URL.applicationSupportDirectory, withIntermediateDirectories: true)
- try data.write(to: Self.savePath)
- let end = Date.now
- logger.info("saved \(data.count) bytes to \(Self.savePath.path) in \(end.timeIntervalSince(start)) seconds")
- } catch {
- logger.error("error saving data: \(error)")
- }
- }
-}
diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
index d875f39..71e47a3 100644
--- a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
@@ -18,7 +18,7 @@ struct TermsOfUseView: View {
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso")
#if os(iOS)
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
#endif
}
}
@@ -41,7 +41,7 @@ struct PrivacyPolicyView: View {
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade")
#if os(iOS)
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
#endif
}
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
index f48022f..c5e523c 100644
--- a/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
@@ -62,7 +62,7 @@ struct LoginEmailView: View {
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
- Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
+ Text("Enviaremos um código de verificação por SMS \ne E-mail para confirmar seu acesso.")
.font(.caption)
.foregroundStyle(Color.gray)
.multilineTextAlignment(.center)
diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
index 5988f7a..db94ee9 100644
--- a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
@@ -114,24 +114,16 @@ struct LoginView: View {
@MainActor
private func applyHiddenStateWithoutAnimation() {
- var transaction = Transaction()
- transaction.disablesAnimations = true
- withTransaction(transaction) {
- heroVisible = false
- textVisible = false
- buttonVisible = false
- }
+ heroVisible = false
+ textVisible = false
+ buttonVisible = false
}
@MainActor
private func showFinalStateWithoutAnimation() {
- var transaction = Transaction()
- transaction.disablesAnimations = true
- withTransaction(transaction) {
- heroVisible = true
- textVisible = true
- buttonVisible = true
- }
+ heroVisible = true
+ textVisible = true
+ buttonVisible = true
}
@MainActor
diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
index c47c965..93c4a93 100644
--- a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
@@ -41,7 +41,7 @@ struct OtpView: View {
.foregroundStyle(AppColors.textMuted)
.padding(.top, 14)
- Text("para \(email)")
+ Text(otpDeliveryMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.lineLimit(1)
@@ -208,6 +208,29 @@ struct OtpView: View {
#endif
}
+ private var otpDeliveryMessage: String {
+ "para o seu telefone \(maskedPhoneForDisplay) e seu email \(maskedEmailForDisplay)"
+ }
+
+ private var maskedPhoneForDisplay: String {
+ let digits = phoneNumber.filter(\.isNumber)
+ guard digits.isEmpty == false else { return "XXXX" }
+
+ let visibleSuffix = String(digits.suffix(min(4, digits.count)))
+ return "XXXX\(visibleSuffix)"
+ }
+
+ private var maskedEmailForDisplay: String {
+ let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.isEmpty == false else { return "XXXX" }
+
+ let parts = trimmed.split(separator: "@", omittingEmptySubsequences: false)
+ guard parts.count == 2 else { return "XXXX" }
+
+ let domain = String(parts[1])
+ return "XXXX@\(domain)"
+ }
+
private func validateOtp() {
let code = otp.filter(\.isNumber)
guard code.count == 8 else { return }
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift
index a077f12..4712a44 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/AddAddressFormView.swift
@@ -128,7 +128,7 @@ struct AddAddressFormView: View {
.padding(.top, 0)
}
.navigationBarBackButtonHidden(true)
- .toolbar(.hidden, for: .navigationBar)
+ .appHiddenNavigationBar()
.onAppear {
populateFromExistingAddressIfNeeded()
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift
index 07766a4..d195279 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressComponents.swift
@@ -129,7 +129,6 @@ struct AddressCard: View {
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
- .truncationMode(.tail)
.minimumScaleFactor(0.9)
if item.isPrimary {
@@ -137,9 +136,7 @@ struct AddressCard: View {
.font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
- .fixedSize(horizontal: true, vertical: false)
- .padding(.horizontal, 9)
- .padding(.vertical, 5)
+ .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
.background(AppColors.tertiary)
.clipShape(Capsule())
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift
index f710b00..5b199a5 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/AddressesView.swift
@@ -79,7 +79,7 @@ struct AddressesView: View {
item: addressToListItem(address, isPrimary: isSelected),
onEdit: { beginEditing(address) }
)
- .contentShape(Rectangle())
+ .appContentShape(Rectangle())
.onTapGesture {
if openSwipeRowId == rowId {
openSwipeRowId = nil
@@ -106,7 +106,7 @@ struct AddressesView: View {
}
.navigationBarBackButtonHidden(true)
- .toolbar(.hidden, for: .navigationBar)
+ .appHiddenNavigationBar()
.sheet(isPresented: $openAddAddressForm) {
NavigationStack {
AddAddressFormView(existingAddress: editingAddress) { updatedAddresses, wasEditing in
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift
index ffd02e2..701c1c9 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/CartView.swift
@@ -6,7 +6,6 @@ struct CartView: View {
@State var openCheckout = false
@State var couponCode = ""
@State var appliedCouponCode: String? = nil
- @State var discountValue: Double = 0
@State var deliveryFee: Double? = nil
@State var selectedCustomerAddress: CustomerAddress? = nil
@State var isLoadingDeliveryFee = false
@@ -38,8 +37,8 @@ struct CartView: View {
}
.padding(.horizontal, 20)
- couponSection
- .padding(.horizontal, 20)
+// couponSection
+// .padding(.horizontal, 20)
summarySection
.padding(.horizontal, 20)
@@ -62,7 +61,25 @@ struct CartView: View {
}
private var totalValue: Double {
- max(0, subtotalValue + (deliveryFee ?? 0) - discountValue)
+ max(0, subtotalValue + (deliveryFee ?? 0) - effectiveDiscountValue)
+ }
+
+ private var effectiveDiscountValue: Double {
+ let normalizedCoupon = (appliedCouponCode ?? "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .uppercased()
+
+ if normalizedCoupon == "DESCONTO10" {
+ return min(subtotalValue, subtotalValue * 0.1)
+ }
+ return 0
+ }
+
+ private var discountLabelValue: String {
+ if effectiveDiscountValue <= 0.0001 {
+ return formatCurrency(0)
+ }
+ return "-\(formatCurrency(effectiveDiscountValue))"
}
private var deliveryFeeWatchKey: String {
@@ -123,7 +140,7 @@ struct CartView: View {
summaryRow(title: "Subtotal", value: formatCurrency(subtotalValue))
summaryRow(title: "Taxa de Entrega", value: deliveryFeeLabel)
- summaryRow(title: "Desconto", value: "-\(formatCurrency(discountValue))", valueColor: Color.red)
+ summaryRow(title: "Desconto", value: discountLabelValue, valueColor: effectiveDiscountValue > 0 ? Color.red : AppColors.textMuted)
Divider()
@@ -237,18 +254,15 @@ struct CartView: View {
let normalized = couponCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard normalized.isEmpty == false else {
appliedCouponCode = nil
- discountValue = 0
return
}
if normalized == "DESCONTO10" {
appliedCouponCode = normalized
- discountValue = min(subtotalValue, subtotalValue * 0.1)
return
}
appliedCouponCode = nil
- discountValue = 0
}
private func formatCurrency(_ value: Double) -> String {
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
index d9f8ec3..a7ddc49 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift
@@ -119,8 +119,8 @@ struct CheckoutView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Finalizar Pedido")
- .navigationBarTitleDisplayMode(.inline)
- .safeAreaInset(edge: .bottom) {
+ .appInlineNavigationTitle()
+ .appBottomSafeAreaInset {
bottomBar
}
.task {
@@ -433,7 +433,7 @@ struct CheckoutView: View {
}
.padding(.horizontal, 14)
.padding(.top, 14)
- .contentShape(Rectangle())
+ .appContentShape(Rectangle())
.onTapGesture(perform: onTap)
content()
@@ -659,7 +659,7 @@ struct PaymentPixView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento via PIX")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.task {
tracker.onOrderUpdated = { updated in
latestOrder = updated
@@ -851,7 +851,7 @@ struct PaymentCardView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Pagamento")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.task {
tracker.onOrderUpdated = { updated in
latestOrder = updated
@@ -879,8 +879,7 @@ struct PaymentCardView: View {
.font(AppTypography.caption)
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: text)
- .textInputAutocapitalization(.never)
- .disableAutocorrection(true)
+ .appNoAutoCap()
.padding(.horizontal, 12)
.frame(height: 46)
.background(AppColors.backgroundLight)
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift b/pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift
new file mode 100644
index 0000000..ef2613e
--- /dev/null
+++ b/pedi-foods/Sources/PediFoods/Views/Main/FiltersModalView.swift
@@ -0,0 +1,235 @@
+import SwiftUI
+
+struct FiltersModalView: View {
+ @Binding var appState: AppState
+ @Environment(\.dismiss) var dismiss
+ @State var draftFilters: HomeFiltersState
+
+ init(appState: Binding) {
+ _appState = appState
+ _draftFilters = State(initialValue: appState.wrappedValue.homeFilters)
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 28) {
+ sortSection
+ categoriesSection
+ priceSection
+ distanceSection
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 22)
+ .padding(.bottom, 120)
+ }
+
+ applyButton
+ .padding(.horizontal, 24)
+ .padding(.vertical, 18)
+ .background(AppColors.backgroundLight)
+ }
+ .background(AppColors.backgroundLight.ignoresSafeArea())
+ }
+
+ var header: some View {
+ HStack {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "xmark")
+ .font(.system(size: 20, weight: .medium))
+ .foregroundStyle(AppColors.textPrimary)
+ .frame(width: 36, height: 36)
+ }
+ .buttonStyle(.plain)
+
+ Spacer()
+
+ Text("Filtros")
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+
+ Spacer()
+
+ Button("Limpar") {
+ draftFilters.reset()
+ draftFilters.availableCategories = appState.homeFilters.availableCategories
+ }
+ .font(AppTypography.heading3)
+ .foregroundStyle(AppColors.secondary)
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 24)
+ .padding(.top, 14)
+ .padding(.bottom, 12)
+ .overlay(alignment: .bottom) {
+ Divider().overlay(Color.black.opacity(0.08))
+ }
+ }
+
+ var sortSection: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Text("Ordenar por")
+ .font(AppTypography.heading1)
+ .foregroundStyle(AppColors.textPrimary)
+
+ VStack(spacing: 10) {
+ ForEach(HomeSortOption.allCases) { option in
+ Button {
+ draftFilters.sortOption = option
+ } label: {
+ HStack(spacing: 14) {
+ Circle()
+ .fill(option == draftFilters.sortOption ? AppColors.tertiary : AppColors.surface)
+ .frame(width: 44, height: 44)
+ .overlay(
+ Image(systemName: option.icon)
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(option == draftFilters.sortOption ? AppColors.textPrimary : AppColors.textMuted)
+ )
+
+ Text(option.title)
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+
+ Spacer()
+
+ Circle()
+ .stroke(option == draftFilters.sortOption ? Color.black : Color.black.opacity(0.2), lineWidth: 2)
+ .frame(width: 28, height: 28)
+ .overlay(
+ Circle()
+ .fill(option == draftFilters.sortOption ? Color.black : Color.clear)
+ .frame(width: 14, height: 14)
+ )
+ }
+ .padding(.horizontal, 16)
+ .frame(height: 88)
+ .background(Color.black.opacity(0.03))
+ .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ var categoriesSection: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Divider().overlay(Color.black.opacity(0.08))
+
+ HStack(alignment: .center) {
+ Text("Categorias")
+ .font(AppTypography.heading1)
+ .foregroundStyle(AppColors.textPrimary)
+ Spacer()
+ Text("Ver todas")
+ .font(AppTypography.heading3)
+ .foregroundStyle(AppColors.textMuted)
+ }
+
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 10)], alignment: .leading, spacing: 10) {
+ ForEach(draftFilters.availableCategories, id: \.self) { category in
+ let isSelected = draftFilters.selectedCategories.contains(category)
+ Button(category) {
+ if isSelected {
+ draftFilters.selectedCategories.remove(category)
+ } else {
+ draftFilters.selectedCategories.insert(category)
+ }
+ }
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+ .padding(.horizontal, 20)
+ .padding(.vertical, 12)
+ .background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
+ .overlay(
+ Capsule().stroke(isSelected ? Color.clear : Color.black.opacity(0.12), lineWidth: 1)
+ )
+ .clipShape(Capsule())
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ var priceSection: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Divider().overlay(Color.black.opacity(0.08))
+
+ Text("Preço")
+ .font(AppTypography.heading1)
+ .foregroundStyle(AppColors.textPrimary)
+
+ HStack(spacing: 12) {
+ ForEach(HomePriceTier.allCases) { tier in
+ let isSelected = draftFilters.selectedPriceTier == tier
+ Button(tier.rawValue) {
+ draftFilters.selectedPriceTier = isSelected ? nil : tier
+ }
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+ .frame(maxWidth: .infinity)
+ .frame(height: 68)
+ .background(isSelected ? AppColors.tertiary.opacity(0.7) : AppColors.surface)
+ .overlay(
+ RoundedRectangle(cornerRadius: 24, style: .continuous)
+ .stroke(isSelected ? Color.black : Color.black.opacity(0.12), lineWidth: isSelected ? 2 : 1)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ var distanceSection: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Divider().overlay(Color.black.opacity(0.08))
+
+ HStack {
+ Text("Distância")
+ .font(AppTypography.heading1)
+ .foregroundStyle(AppColors.textPrimary)
+ Spacer()
+ Text("Até \(Int(draftFilters.maxDistanceKm))km")
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+ }
+
+ Slider(value: $draftFilters.maxDistanceKm, in: 1...10, step: 1)
+ .tint(AppColors.tertiary)
+
+ HStack {
+ Text("1km")
+ Spacer()
+ Text("10km")
+ }
+ .font(AppTypography.heading3)
+ .foregroundStyle(AppColors.textMuted)
+ }
+ }
+
+ var applyButton: some View {
+ Button {
+ appState.homeFilters.sortOption = draftFilters.sortOption
+ appState.homeFilters.selectedCategories = draftFilters.selectedCategories
+ appState.homeFilters.selectedPriceTier = draftFilters.selectedPriceTier
+ appState.homeFilters.maxDistanceKm = draftFilters.maxDistanceKm
+ appState.homeFilters.availableCategories = draftFilters.availableCategories
+ dismiss()
+ } label: {
+ Text("Aplicar Filtros")
+ .font(AppTypography.heading2)
+ .foregroundStyle(AppColors.textPrimary)
+ .frame(maxWidth: .infinity)
+ .frame(height: 62)
+ .background(AppColors.tertiary.opacity(0.7))
+ .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
+ }
+ .buttonStyle(.plain)
+ }
+}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift
new file mode 100644
index 0000000..7cbf61c
--- /dev/null
+++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView+Filtering.swift
@@ -0,0 +1,216 @@
+import Foundation
+
+extension HomeView {
+ var filteredStores: [StoreSummary] {
+ let normalizedQuery = normalizeSearch(searchText)
+ var list = stores
+
+ if appState.homeFilters.selectedCategories.isEmpty == false {
+ let allowed = Set(appState.homeFilters.selectedCategories.map(normalizeSearch))
+ list = list.filter { store in
+ let category = normalizeSearch(store.category ?? "")
+ return allowed.contains(category)
+ }
+ }
+
+ if let tier = appState.homeFilters.selectedPriceTier {
+ list = list.filter { store in
+ guard let fee = store.deliveryFee else { return false }
+ return matchesPriceTier(fee: fee, tier: tier)
+ }
+ }
+
+ let maxDistance = appState.homeFilters.maxDistanceKm
+ list = list.filter { store in
+ guard let distance = store.distance else { return true }
+ return distance <= maxDistance
+ }
+
+ if normalizedQuery.isEmpty == false {
+ list = list.filter { store in
+ matchesSearch(store: store, query: normalizedQuery)
+ }
+ }
+
+ return sortStores(list, query: normalizedQuery)
+ }
+
+ var featuredStoresCards: [FeaturedStoreCardModel] {
+ Array(filteredStores.prefix(5)).map(mapStoreToCard)
+ }
+
+ var filteredStoreCards: [FeaturedStoreCardModel] {
+ let featuredIds = Set(filteredStores.prefix(5).map(\.id))
+ let remaining = filteredStores.filter { featuredIds.contains($0.id) == false }
+ return Array(remaining.prefix(20)).map(mapStoreToCard)
+ }
+
+ var emptyResultMessage: String {
+ if normalizeSearch(searchText).isEmpty == false {
+ return "Nenhum resultado para \"\(searchText.trimmingCharacters(in: .whitespacesAndNewlines))\"."
+ }
+ return "Nenhum estabelecimento encontrado com os filtros selecionados."
+ }
+
+ func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
+ let coverURL = resolveStoreMediaURL(store.cover)
+ let logoURL = resolveStoreMediaURL(store.logo)
+ return FeaturedStoreCardModel(
+ id: store.id,
+ name: store.name,
+ rating: store.rating ?? 0,
+ reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
+ distance: formatDistance(store.distance),
+ category: store.category ?? "Loja",
+ promoText: nil,
+ isFavorite: appState.favorites.storeIds.contains(store.id),
+ iconName: "storefront",
+ imageURL: coverURL ?? logoURL,
+ logoURL: logoURL,
+ coverURL: coverURL,
+ isOpen: store.isOpen ?? true,
+ statusLabel: store.statusLabel
+ )
+ }
+
+ func resolveStoreMediaURL(_ raw: String?) -> String? {
+ ImageSourceResolver.resolve(raw)
+ }
+
+ var profilePictureURL: String? {
+ resolveStoreMediaURL(appState.profile.profilePicture)
+ }
+
+ 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"
+ }
+
+ func scheduleSearchIndexUpdate() {
+ searchDebounceToken += 1
+ let token = searchDebounceToken
+ Task {
+ try? await Task.sleep(nanoseconds: 220_000_000)
+ guard token == searchDebounceToken else { return }
+ await loadProductIndexForSearchIfNeeded()
+ }
+ }
+
+ @MainActor
+ func loadProductIndexForSearchIfNeeded() async {
+ let query = normalizeSearch(searchText)
+ guard query.isEmpty == false else { return }
+
+ let candidates = filteredStores
+ .filter { productSearchIndexByStoreId[$0.id] == nil }
+ .prefix(10)
+
+ guard candidates.isEmpty == false else { return }
+
+ await withTaskGroup(of: (String, [String]?).self) { group in
+ for store in candidates {
+ group.addTask {
+ do {
+ let response = try await ApiService().storeCatalog(storeId: store.id)
+ let products = response.result?.flatMap(\.products) ?? []
+ let names = products.map(\.name)
+ return (store.id, names)
+ } catch {
+ return (store.id, nil)
+ }
+ }
+ }
+
+ for await result in group {
+ let names = result.1 ?? []
+ productSearchIndexByStoreId[result.0] = names
+ }
+ }
+ }
+
+ func normalizeSearch(_ value: String) -> String {
+ value
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
+ .lowercased()
+ }
+
+ func matchesSearch(store: StoreSummary, query: String) -> Bool {
+ let storeName = normalizeSearch(store.name)
+ if storeName.contains(query) {
+ return true
+ }
+
+ let category = normalizeSearch(store.category ?? "")
+ if category.contains(query) {
+ return true
+ }
+
+ let products = productSearchIndexByStoreId[store.id] ?? []
+ return products.contains { normalizeSearch($0).contains(query) }
+ }
+
+ func sortStores(_ list: [StoreSummary], query: String) -> [StoreSummary] {
+ switch appState.homeFilters.sortOption {
+ case .relevance:
+ return list.sorted { lhs, rhs in
+ let lhsScore = relevanceScore(for: lhs, query: query)
+ let rhsScore = relevanceScore(for: rhs, query: query)
+ if lhsScore != rhsScore {
+ return lhsScore > rhsScore
+ }
+ return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
+ }
+ case .rating:
+ return list.sorted { ($0.rating ?? 0) > ($1.rating ?? 0) }
+ case .deliveryTime:
+ return list.sorted { estimatedDeliveryMinutes($0.deliveryTime) < estimatedDeliveryMinutes($1.deliveryTime) }
+ case .price:
+ return list.sorted { ($0.deliveryFee ?? .greatestFiniteMagnitude) < ($1.deliveryFee ?? .greatestFiniteMagnitude) }
+ }
+ }
+
+ func relevanceScore(for store: StoreSummary, query: String) -> Double {
+ guard query.isEmpty == false else {
+ let positive = Double(store.positiveReviews ?? store.reviewsCount ?? 0)
+ return positive + (store.rating ?? 0) * 10
+ }
+
+ let name = normalizeSearch(store.name)
+ let category = normalizeSearch(store.category ?? "")
+ let products = productSearchIndexByStoreId[store.id] ?? []
+
+ var score = 0.0
+ if name.hasPrefix(query) { score += 200 }
+ if name.contains(query) { score += 120 }
+ if category.contains(query) { score += 70 }
+ if products.contains(where: { normalizeSearch($0).contains(query) }) { score += 90 }
+ score += (store.rating ?? 0) * 10
+ score += Double(store.positiveReviews ?? store.reviewsCount ?? 0) * 0.02
+ return score
+ }
+
+ func estimatedDeliveryMinutes(_ value: String?) -> Int {
+ guard let value else { return Int.max }
+ let digits = value.compactMap { $0.isNumber ? String($0) : " " }.joined()
+ let parts = digits
+ .split(separator: " ")
+ .compactMap { Int($0) }
+ if let min = parts.min() {
+ return min
+ }
+ return Int.max
+ }
+
+ func matchesPriceTier(fee: Double, tier: HomePriceTier) -> Bool {
+ switch tier {
+ case .low: return fee <= 5
+ case .medium: return fee > 5 && fee <= 10
+ case .high: return fee > 10 && fee <= 20
+ case .veryHigh: return fee > 20
+ }
+ }
+}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift
index 03e6441..f2d28fd 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift
@@ -1,4 +1,3 @@
-import Foundation
import SwiftUI
#if os(iOS)
import LCEssentials
@@ -14,12 +13,12 @@ struct HomeView: View {
.init(id: "all", title: "Todas", systemIcon: "line.3.horizontal.decrease.circle", emojiIcon: nil)
]
@State var scrollOffset: CGFloat = 0
- @State var collapseBaseOffset: CGFloat = 0
- @State var collapseDragStartOffset: CGFloat? = nil
@State var hasRequestedLocation = false
@State var isLoadingStores = false
@State var storesError: String? = nil
@State var stores: [StoreSummary] = []
+ @State var productSearchIndexByStoreId: [String: [String]] = [:]
+ @State var searchDebounceToken = 0
private let specials: [SpecialOfferCardModel] = [
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
@@ -43,28 +42,12 @@ struct HomeView: View {
}
.refreshable {
await bootstrapStoresFlow(
- forceLocationRefresh: true,
- category: selectedCategory == "all" ? nil : selectedCategory,
+ forceNetworkRefresh: true,
+ category: selectedCategoryQueryValue,
refreshCategories: true
)
}
.background(scrollOffsetObserver)
- .simultaneousGesture(
- DragGesture(minimumDistance: 0)
- .onChanged { value in
- let range = headerExpandedHeight - headerCollapsedHeight
- if collapseDragStartOffset == nil {
- collapseDragStartOffset = collapseBaseOffset
- }
- let start = collapseDragStartOffset ?? collapseBaseOffset
- let candidate = start - value.translation.height
- scrollOffset = clamp(value: candidate, lower: 0, upper: range)
- }
- .onEnded { _ in
- collapseBaseOffset = scrollOffset
- collapseDragStartOffset = nil
- }
- )
header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top)
@@ -79,18 +62,24 @@ struct HomeView: View {
await bootstrapStoresFlow(refreshCategories: true)
}
}
- collapseBaseOffset = scrollOffset
}
.onChange(of: addressCacheScope) { _, _ in
guard hasRequestedLocation else { return }
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
- category: selectedCategory == "all" ? nil : selectedCategory,
+ category: selectedCategoryQueryValue,
refreshCategories: true
)
}
}
+ .onChange(of: searchText) { _, _ in
+ scheduleSearchIndexUpdate()
+ }
+ .onChange(of: appState.homeFilters.sortOption) { _, _ in scheduleSearchIndexUpdate() }
+ .onChange(of: appState.homeFilters.selectedCategories) { _, _ in scheduleSearchIndexUpdate() }
+ .onChange(of: appState.homeFilters.selectedPriceTier) { _, _ in scheduleSearchIndexUpdate() }
+ .onChange(of: appState.homeFilters.maxDistanceKm) { _, _ in scheduleSearchIndexUpdate() }
}
private var contentStack: some View {
@@ -153,7 +142,7 @@ struct HomeView: View {
Task {
await bootstrapStoresFlow(
forceLocationRefresh: true,
- category: selectedCategory == "all" ? nil : selectedCategory,
+ category: selectedCategoryQueryValue,
refreshCategories: true
)
}
@@ -162,14 +151,14 @@ struct HomeView: View {
.foregroundStyle(AppColors.primary)
}
.padding(.horizontal, 20)
- } else if nearbyStoreCards.isEmpty {
- Text("Nenhum estabelecimento encontrado próximo à sua localização.")
+ } else if filteredStoreCards.isEmpty {
+ Text(emptyResultMessage)
.font(AppTypography.body)
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 20)
} else {
VStack(spacing: 16) {
- ForEach(nearbyStoreCards) { store in
+ ForEach(filteredStoreCards) { store in
NavigationLink {
StoreDetailView(
storeId: store.id,
@@ -275,6 +264,9 @@ struct HomeView: View {
}
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) {
+ appState.homeFilters.availableCategories = categories
+ .filter { $0.id.lowercased() != "all" }
+ .map(\.title)
appState.activeModal = .filters
}
.offset(y: collapseProgress * -120)
@@ -315,7 +307,7 @@ struct HomeView: View {
guard category.id != selectedCategory else { return }
selectedCategory = category.id
Task {
- await bootstrapStoresFlow(category: category.id == "all" ? nil : category.id)
+ await bootstrapStoresFlow(category: category.id.lowercased() == "all" ? nil : category.title)
}
}
}
@@ -338,77 +330,14 @@ struct HomeView: View {
}
}
- private var storesByPositiveReviews: [StoreSummary] {
- stores.sorted { lhs, rhs in
- let lhsPositive = lhs.positiveReviews ?? lhs.reviewsCount ?? 0
- let rhsPositive = rhs.positiveReviews ?? rhs.reviewsCount ?? 0
- if lhsPositive != rhsPositive {
- return lhsPositive > rhsPositive
- }
-
- let lhsRating = lhs.rating ?? 0
- let rhsRating = rhs.rating ?? 0
- if lhsRating != rhsRating {
- return lhsRating > rhsRating
- }
-
- return (lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
- }
- }
-
- private var featuredStoresCards: [FeaturedStoreCardModel] {
- Array(storesByPositiveReviews.prefix(5)).map(mapStoreToCard)
- }
-
- private var nearbyStoreCards: [FeaturedStoreCardModel] {
- let featuredIds = Set(storesByPositiveReviews.prefix(5).map(\.id))
- let remaining = storesByPositiveReviews.filter { featuredIds.contains($0.id) == false }
- return Array(remaining.prefix(20)).map(mapStoreToCard)
- }
-
- private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
- let coverURL = resolveStoreMediaURL(store.cover)
- let logoURL = resolveStoreMediaURL(store.logo)
- return FeaturedStoreCardModel(
- id: store.id,
- name: store.name,
- rating: store.rating ?? 0,
- reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
- distance: formatDistance(store.distance),
- category: store.category ?? "Loja",
- promoText: nil,
- isFavorite: appState.favorites.storeIds.contains(store.id),
- iconName: "storefront",
- imageURL: coverURL ?? logoURL,
- logoURL: logoURL,
- coverURL: coverURL,
- isOpen: store.isOpen ?? true,
- statusLabel: store.statusLabel
- )
- }
-
- private func resolveStoreMediaURL(_ raw: String?) -> String? {
- ImageSourceResolver.resolve(raw)
- }
-
- private var profilePictureURL: String? {
- resolveStoreMediaURL(appState.profile.profilePicture)
- }
-
- 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,
+ forceNetworkRefresh: Bool = false,
category: String? = nil,
refreshCategories: Bool = false
) async {
+ if isLoadingStores { return }
isLoadingStores = true
storesError = nil
@@ -432,6 +361,7 @@ struct HomeView: View {
)
if forceLocationRefresh == false,
+ forceNetworkRefresh == false,
let cachedStores: [StoreSummary] = AppContentCache.shared.value(for: storesCacheKey, as: [StoreSummary].self) {
isLoadingStores = false
stores = cachedStores
@@ -445,7 +375,10 @@ struct HomeView: View {
#endif
AppContentCache.shared.set(cachedStores, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) {
- await loadHomeCategories(withFallbackStores: cachedStores, forceRefresh: forceLocationRefresh)
+ await loadHomeCategories(
+ withFallbackStores: cachedStores,
+ forceRefresh: forceLocationRefresh || forceNetworkRefresh
+ )
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
@@ -478,13 +411,20 @@ struct HomeView: View {
AppContentCache.shared.set(results, for: storesCacheKey, ttl: AppCacheTTL.homeStores)
AppContentCache.shared.set(results, for: AppCacheKey.homeStoresLatestSnapshot, ttl: AppCacheTTL.twoHours)
if refreshCategories || (category == nil && categories.count <= 1) {
- await loadHomeCategories(withFallbackStores: results, forceRefresh: forceLocationRefresh)
+ await loadHomeCategories(
+ withFallbackStores: results,
+ forceRefresh: forceLocationRefresh || forceNetworkRefresh
+ )
if categories.contains(where: { $0.id == selectedCategory }) == false {
selectedCategory = "all"
}
}
storesError = nil
} catch {
+ if isCancelledRequest(error) {
+ isLoadingStores = false
+ return
+ }
isLoadingStores = false
stores = []
storesError = storesUserMessage(error)
@@ -498,7 +438,6 @@ struct HomeView: View {
// Use only upward displacement for collapse and ignore top bounce.
let normalized = max(0, y)
scrollOffset = normalized
- collapseBaseOffset = normalized
}
.frame(width: 0, height: 0)
#else
@@ -511,15 +450,49 @@ struct HomeView: View {
let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
- let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil"
- let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil"
+ let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil"
+ let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil"
return "\(selected)|\(display)|\(lat)|\(lng)"
}
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
- let latKey = lat.map { String(format: "%.4f", $0) } ?? "nil"
- let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil"
+ let latKey = lat.map(formatCoordinateCache) ?? "nil"
+ let lngKey = lng.map(formatCoordinateCache) ?? "nil"
return "stores:\(latKey):\(lngKey):\(normalizedCategory)"
}
+
+ private func formatCoordinateScope(_ value: Double) -> String {
+ String((value * 100_000).rounded() / 100_000)
+ }
+
+ private func formatCoordinateCache(_ value: Double) -> String {
+ String((value * 10_000).rounded() / 10_000)
+ }
+
+ private var selectedCategoryQueryValue: String? {
+ guard selectedCategory.lowercased() != "all" else { return nil }
+ guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil }
+ return selected.title
+ }
+
+ private func isCancelledRequest(_ error: Error) -> Bool {
+ if error is CancellationError {
+ return true
+ }
+
+ if let networkError = error as? NetworkError {
+ switch networkError {
+ case .cancelled:
+ return true
+ case .transportError(let message):
+ let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ return normalized.contains("cancel")
+ default:
+ break
+ }
+ }
+
+ return error.localizedDescription.lowercased().contains("cancel")
+ }
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift
index 1494bab..10cd7b4 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift
@@ -23,8 +23,8 @@ struct OrderDetailsView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido")
- .navigationBarTitleDisplayMode(.inline)
- .safeAreaInset(edge: .bottom) {
+ .appInlineNavigationTitle()
+ .appBottomSafeAreaInset {
VStack {
reorderButton
.padding(.horizontal, 20)
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift
index 38129b3..70006c6 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift
@@ -12,14 +12,17 @@ private struct TrackingStep: Identifiable {
struct OrderTrackingView: View {
let orderId: String
let initialShortId: String?
+ @Environment(\.openURL) var openURL
@State var isLoading = true
@State var errorMessage: String? = nil
@State var order: PublicOrderResult? = nil
+ @State var storeContactPhone: String? = nil
@State var tracker = OrderRealtimeTracker()
@State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false
+ @State var reviewSavedObserver: Any?
var body: some View {
ScrollView(showsIndicators: false) {
@@ -41,7 +44,7 @@ struct OrderTrackingView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Pedido \(displayOrderTitle)")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.alert("Motivo do cancelamento", isPresented: $showCancellationReason) {
Button("Fechar", role: .cancel) {}
} message: {
@@ -51,26 +54,49 @@ struct OrderTrackingView: View {
await loadInitialOrder()
tracker.onOrderUpdated = { updated in
order = updated
+ if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
+ storeContactPhone = inlinePhone
+ }
isLoading = false
errorMessage = nil
}
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
}
+ .onAppear {
+ attachReviewSavedObserverIfNeeded()
+ }
.onDisappear {
tracker.stop()
+ detachReviewSavedObserver()
}
.navigationDestination(item: $reviewDraft) { draft in
MyReviewsView(initialOrder: draft)
}
- .onReceive(NotificationCenter.default.publisher(for: .orderReviewDidSave)) { payload in
+ }
+
+ private func attachReviewSavedObserverIfNeeded() {
+ guard reviewSavedObserver == nil else { return }
+ reviewSavedObserver = NotificationCenter.default.addObserver(
+ forName: .orderReviewDidSave,
+ object: nil,
+ queue: nil
+ ) { payload in
guard let orderIdFromEvent = payload.userInfo?["orderId"] as? String else { return }
- let currentOrderId = (order?.id ?? orderId).trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ let currentOrderId = (order?.id ?? orderId)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
didSaveReviewForCurrentOrder = true
}
}
}
+ private func detachReviewSavedObserver() {
+ guard let reviewSavedObserver else { return }
+ NotificationCenter.default.removeObserver(reviewSavedObserver)
+ self.reviewSavedObserver = nil
+ }
+
private var topHeader: some View {
HStack(spacing: 10) {
Circle()
@@ -224,7 +250,9 @@ struct OrderTrackingView: View {
}
private var contactButton: some View {
- Button("CONTATO") {}
+ Button("CONTATO") {
+ openStoreWhatsApp()
+ }
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: .infinity, minHeight: 56)
@@ -747,23 +775,82 @@ struct OrderTrackingView: View {
@MainActor
private func loadInitialOrder() async {
- logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)")
+ logger.info("OrderTracking initial fetch orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error {
errorMessage = response.message ?? "Não foi possível carregar o pedido."
- logger.error("OrderTracking initial fetch API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
+ logger.error("OrderTracking initial fetch API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} else if let result = response.result {
order = result
+ storeContactPhone = result.storePhone
errorMessage = nil
- logger.info("OrderTracking initial fetch success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
+ logger.info("OrderTracking initial fetch success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
+ await refreshStoreContactPhone(for: result)
}
} catch {
errorMessage = "Não foi possível carregar o pedido."
- logger.error("OrderTracking initial fetch failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
+ logger.error("OrderTracking initial fetch failure orderId=\(orderId) error=\(error.localizedDescription)")
}
isLoading = false
}
+
+ @MainActor
+ private func refreshStoreContactPhone(for order: PublicOrderResult) async {
+ guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
+ if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
+ storeContactPhone = inlinePhone
+ }
+ return
+ }
+
+ do {
+ let response = try await ApiService().storeInfo(storeId: storeId)
+ if response.error == false, let result = response.result {
+ let phone = (result.whatsapp ?? result.phone ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if phone.isEmpty == false {
+ storeContactPhone = phone
+ return
+ }
+ }
+ } catch {
+ // Fallback handled below.
+ }
+
+ if let inlinePhone = order.storePhone, inlinePhone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
+ storeContactPhone = inlinePhone
+ }
+ }
+
+ private func openStoreWhatsApp() {
+ guard let phoneRaw = storeContactPhone,
+ let url = makeWhatsAppURL(from: phoneRaw) else {
+ SnackbarCenter.shared.show(
+ title: "Telefone da loja indisponível.",
+ style: .warning,
+ icon: "exclamationmark.triangle.fill",
+ duration: 2.8
+ )
+ return
+ }
+ openURL(url)
+ }
+
+ private func makeWhatsAppURL(from phoneRaw: String) -> URL? {
+ var digits = phoneRaw.filter(\.isNumber)
+ if digits.isEmpty { return nil }
+
+ if digits.hasPrefix("0") {
+ digits = String(digits.drop(while: { $0 == "0" }))
+ }
+
+ if digits.hasPrefix("55") == false && (digits.count == 10 || digits.count == 11) {
+ digits = "55" + digits
+ }
+
+ guard digits.count >= 12 else { return nil }
+ return URL(string: "https://wa.me/\(digits)")
+ }
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift
index 607f064..1325268 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift
@@ -39,7 +39,7 @@ struct OrdersView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Meus Pedidos")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.task {
await loadOrdersIfNeeded()
await refreshStoreRatings()
@@ -137,7 +137,7 @@ struct OrdersView: View {
.lineLimit(1)
.minimumScaleFactor(0.5)
.buttonStyle(.plain)
- .layoutPriority(0)
+ .appLayoutPriority(0)
Spacer(minLength: 8)
@@ -169,7 +169,7 @@ struct OrdersView: View {
.clipShape(Capsule())
}
.buttonStyle(.plain)
- .layoutPriority(2)
+ .appLayoutPriority(2)
}
}
.padding(18)
@@ -550,6 +550,7 @@ extension AppOrderSummary {
paymentMethod: tracked.paymentMethod,
deliveryType: tracked.deliveryType,
storeName: tracked.storeName,
+ storePhone: tracked.storePhone,
storeLogoURL: tracked.storeLogoURL,
createdAt: tracked.createdAt,
updatedAt: tracked.updatedAt
@@ -571,6 +572,7 @@ extension AppOrderSummary {
paymentMethod: String?,
deliveryType: String?,
storeName: String?,
+ storePhone: String?,
storeLogoURL: String?,
createdAt: String?,
updatedAt: String?
@@ -589,6 +591,7 @@ extension AppOrderSummary {
self.paymentMethod = paymentMethod
self.deliveryType = deliveryType
self.storeName = storeName
+ self.storePhone = storePhone
self.storeLogoURL = storeLogoURL
self.createdAt = createdAt
self.updatedAt = updatedAt
@@ -716,16 +719,16 @@ struct OrderEntryDestinationView: View {
@MainActor
func fetchOrderForRouting() async -> PublicOrderResult? {
- logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)")
+ logger.info("OrderEntry fetch route orderId=\(orderId)")
do {
let response = try await ApiService().publicOrder(orderId: orderId)
if response.error == false, let result = response.result {
- logger.info("OrderEntry fetch route success orderId=\(orderId, privacy: .public) status=\((result.status ?? "nil"), privacy: .public) paymentStatus=\((result.paymentStatus ?? "nil"), privacy: .public)")
+ logger.info("OrderEntry fetch route success orderId=\(orderId) status=\(result.status ?? "nil") paymentStatus=\(result.paymentStatus ?? "nil")")
return result
}
- logger.error("OrderEntry fetch route API error orderId=\(orderId, privacy: .public) message=\((response.message ?? "unknown"), privacy: .public)")
+ logger.error("OrderEntry fetch route API error orderId=\(orderId) message=\(response.message ?? "unknown")")
} catch {
- logger.error("OrderEntry fetch route failure orderId=\(orderId, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
+ logger.error("OrderEntry fetch route failure orderId=\(orderId) error=\(error.localizedDescription)")
}
return nil
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift
index 2a29537..3170436 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaFlavorAddonsSheet.swift
@@ -78,12 +78,10 @@ struct PizzaFlavorAddonsSheet: View {
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Adicionais")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Concluir") { dismiss() }
- .foregroundStyle(AppColors.primary)
- }
+ .appInlineNavigationTitle()
+ .appTopBarTrailingToolbar {
+ Button("Concluir") { dismiss() }
+ .foregroundStyle(AppColors.primary)
}
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift
index 5999afe..f96b628 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/PizzaProductDetailSheet+Flow.swift
@@ -9,7 +9,7 @@ extension PizzaProductDetailSheet {
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
- ForEach(Array(sizeItems.indices), id: \.self) { index in
+ ForEach(0.. 1 { quantity -= 1 } }) {
@@ -292,12 +292,10 @@ struct PizzaProductDetailSheet: View {
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Monte sua pizza")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Fechar") { dismiss() }
- .foregroundStyle(AppColors.primary)
- }
+ .appInlineNavigationTitle()
+ .appTopBarTrailingToolbar {
+ Button("Fechar") { dismiss() }
+ .foregroundStyle(AppColors.primary)
}
.onAppear {
applyAutoSelections()
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift
index cf4149d..8af117d 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift
@@ -164,7 +164,7 @@ struct ProductDetailSheet: View {
.padding(20)
.padding(.bottom, 80)
}
- .safeAreaInset(edge: .bottom) {
+ .appBottomSafeAreaInset {
HStack(spacing: 12) {
HStack(spacing: 10) {
Button(action: { if quantity > 0 { quantity -= 1 } }) {
@@ -222,12 +222,10 @@ struct ProductDetailSheet: View {
}
.background(AppColors.backgroundLight.ignoresSafeArea())
.navigationTitle("Detalhes")
- .navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .topBarTrailing) {
- Button("Fechar") { dismiss() }
- .foregroundStyle(AppColors.primary)
- }
+ .appInlineNavigationTitle()
+ .appTopBarTrailingToolbar {
+ Button("Fechar") { dismiss() }
+ .foregroundStyle(AppColors.primary)
}
.onAppear {
let existing = currentQuantityForItemId(cartItemId)
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift
index 5d98c11..495ddc8 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift
@@ -1,5 +1,7 @@
import SwiftUI
+#if canImport(LCEssentials) && os(iOS)
import LCEssentials
+#endif
#if canImport(UIKit)
import UIKit
#endif
@@ -89,7 +91,7 @@ struct ProfileView: View {
}
}
.ignoresSafeArea(edges: .top)
- .safeAreaInset(edge: .bottom) {
+ .appBottomSafeAreaInset {
Rectangle()
.fill(AppColors.backgroundLight.opacity(0.8))
.frame(height: tabBarClearance)
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift
index e606d3e..54d9725 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift
@@ -67,7 +67,7 @@ struct MyReviewsView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Minhas Avaliações")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.navigationDestination(item: $selectedDraft) { draft in
OrderReviewView(draft: draft) {
Task { await loadReviewsFromBackend(forceRefresh: true) }
@@ -451,7 +451,7 @@ struct OrderReviewView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Avaliar Pedido")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.onAppear {
if let cachedReview = SessionStateStore.loadOrderReview(orderId: draft.orderId) {
existingReview = cachedReview
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
index 05fae25..f510296 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView+Logic.swift
@@ -50,12 +50,17 @@ extension StoreDetailView {
if let minOrder = info?.minOrder {
return formatCurrency(minOrder)
}
- if let storeDistance, storeDistance.isEmpty == false {
- return storeDistance
- }
return "R$ --"
}
+ var distanceValueLabel: String {
+ let raw = (storeDistance ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ if raw.isEmpty {
+ return "--"
+ }
+ return raw
+ }
+
var isStoreOpen: Bool {
info?.isOpen ?? true
}
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift
index 19f3732..4834975 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift
@@ -1,5 +1,7 @@
import SwiftUI
+#if canImport(LCEssentials) && os(iOS)
import LCEssentials
+#endif
#if canImport(UIKit)
import UIKit
#endif
@@ -48,6 +50,15 @@ struct StoreDetailView: View {
AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) {
+#if os(Android)
+ LazyVStack(spacing: 0) {
+ topSection
+
+ categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
+
+ sectionedProducts
+ }
+#else
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
topSection
@@ -55,6 +66,7 @@ struct StoreDetailView: View {
sectionedProducts
}
+#endif
}
.refreshable {
await loadStoreData(forceRefresh: true)
@@ -74,8 +86,7 @@ struct StoreDetailView: View {
}
}
.navigationBarBackButtonHidden(true)
- .toolbar(.hidden, for: .navigationBar)
- .toolbarBackground(.hidden, for: .navigationBar)
+ .appHiddenNavigationBar()
.task {
guard didLoad == false else { return }
didLoad = true
@@ -263,6 +274,8 @@ struct StoreDetailView: View {
}
HStack(spacing: 0) {
+ statItem(title: "DISTÂNCIA", value: distanceValueLabel)
+ Divider().frame(height: 34)
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
Divider().frame(height: 34)
statItem(title: "ENTREGA", value: deliveryValueLabel)
@@ -469,7 +482,7 @@ struct StoreDetailView: View {
}
.offset(x: 3, y: -3)
.frame(width: 30, height: 30)
- .contentShape(Circle())
+ .appContentShape(Circle())
}
.buttonStyle(.plain)
.frame(width: 30, height: 30)
@@ -481,7 +494,7 @@ struct StoreDetailView: View {
.padding(12)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
- .contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
+ .appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.onTapGesture {
guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView+Android.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView+Android.swift
new file mode 100644
index 0000000..8974bc4
--- /dev/null
+++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView+Android.swift
@@ -0,0 +1,205 @@
+#if os(Android)
+import SwiftUI
+
+struct UserProfileView: View {
+ @Binding var appState: AppState
+
+ @State var name: String = ""
+ @State var email: String = ""
+ @State var phone: String = ""
+ @State var profilePicture: String = ""
+ @State var isSaving = false
+
+ var body: some View {
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 22) {
+ avatarSection
+ formSection
+ saveButton
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 18)
+ .padding(.bottom, UIDevice.bottomNotch + 24)
+ }
+ .background(AppColors.backgroundLight)
+ .navigationTitle("Meu Perfil")
+ .appInlineNavigationTitle()
+ .onAppear {
+ hydrateFromAppState()
+ }
+ }
+
+ private var avatarSection: some View {
+ VStack(spacing: 12) {
+ Circle()
+ .fill(AppColors.brandSoft)
+ .frame(width: 110, height: 110)
+ .overlay {
+ Text(initials)
+ .font(.system(size: 32, weight: .bold))
+ .foregroundStyle(AppColors.primary)
+ }
+
+ Button("Remover") {
+ profilePicture = ""
+ }
+ .font(AppTypography.caption)
+ .foregroundStyle(AppColors.textMuted)
+ .buttonStyle(.plain)
+ .disabled(profilePicture.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private var formSection: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ textFieldSection(title: "Nome", placeholder: "Seu nome completo", text: $name)
+
+ textFieldSection(title: "E-mail", placeholder: "seu@email.com", text: $email)
+ .appNoAutoCap()
+
+ textFieldSection(title: "Telefone", placeholder: "(00) 00000-0000", text: $phone)
+ .onChange(of: phone) { _, newValue in
+ let masked = formatPhoneBR(displayPhoneDigits(newValue))
+ if masked != newValue {
+ phone = masked
+ }
+ }
+ .appNoAutoCap()
+ }
+ .padding(16)
+ .background(AppColors.surface)
+ .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
+ }
+
+ private var saveButton: some View {
+ Button(isSaving ? "Salvando..." : "Salvar Alterações") {
+ Task { await saveProfile() }
+ }
+ .font(AppTypography.button)
+ .foregroundStyle(AppColors.textInverse)
+ .frame(maxWidth: .infinity, minHeight: 56)
+ .background(AppColors.primary)
+ .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
+ .buttonStyle(.plain)
+ .disabled(isSaving || canSave == false)
+ .opacity((isSaving || canSave == false) ? 0.6 : 1.0)
+ }
+
+ private var initials: String {
+ let parts = name
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .split(separator: " ")
+ .prefix(2)
+ let letters = parts.compactMap { $0.first }.map(String.init).joined()
+ return letters.isEmpty ? "PF" : letters.uppercased()
+ }
+
+ private var canSave: Bool {
+ let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalizedPhone = normalizePhoneNumberForAPI(phone)
+ return cleanName.isEmpty == false
+ && cleanEmail.isEmpty == false
+ && cleanEmail.contains("@")
+ && normalizedPhone.isEmpty == false
+ }
+
+ private func hydrateFromAppState() {
+ name = appState.profile.name
+ email = appState.profile.email
+ phone = formatPhoneForDisplay(appState.profile.phone)
+ profilePicture = appState.profile.profilePicture
+ }
+
+ @MainActor
+ private func saveProfile() async {
+ guard canSave else { return }
+
+ let cleanName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let cleanEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalizedPhone = normalizePhoneNumberForAPI(phone)
+ let cleanPhoto = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ isSaving = true
+ defer { isSaving = false }
+
+ do {
+ let response = try await ApiService().updateCustomerProfile(
+ name: cleanName,
+ email: cleanEmail,
+ phoneNumber: normalizedPhone,
+ profilePicture: cleanPhoto.isEmpty ? nil : cleanPhoto
+ )
+
+ if response.error {
+ SnackbarCenter.shared.show(
+ title: response.message ?? "Não foi possível atualizar seu perfil.",
+ style: .error,
+ icon: "xmark.octagon.fill",
+ duration: 3.0
+ )
+ return
+ }
+
+ let customer = response.result
+ appState.profile.id = customer?.id ?? appState.profile.id
+ appState.profile.name = customer?.name ?? cleanName
+ appState.profile.email = customer?.email ?? cleanEmail
+ appState.profile.phone = customer?.phoneNumber ?? normalizedPhone
+ appState.profile.profilePicture = customer?.profilePicture ?? cleanPhoto
+ SessionStateStore.setActiveUserKey(
+ SessionStateStore.makeUserKey(profileId: appState.profile.id, email: appState.profile.email)
+ )
+
+ SnackbarCenter.shared.show(
+ title: "Perfil atualizado com sucesso.",
+ style: .success,
+ icon: "checkmark.circle.fill",
+ duration: 2.0
+ )
+ } catch {
+ SnackbarCenter.shared.show(
+ title: "Não foi possível atualizar seu perfil.",
+ style: .error,
+ icon: "xmark.octagon.fill",
+ duration: 3.0
+ )
+ }
+ }
+
+ private func formatPhoneForDisplay(_ raw: String) -> String {
+ let digits = displayPhoneDigits(raw)
+ if digits.isEmpty { return "" }
+ return formatPhoneBR(digits)
+ }
+
+ private func displayPhoneDigits(_ raw: String) -> String {
+ var digits = raw.filter(\.isNumber)
+ if digits.hasPrefix("55"), digits.count > 11 {
+ digits = String(digits.dropFirst(2))
+ }
+ return String(digits.prefix(11))
+ }
+
+ private func textFieldSection(title: String, placeholder: String, text: Binding) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(title)
+ .font(AppTypography.caption)
+ .foregroundStyle(AppColors.textMuted)
+
+ TextField(placeholder, text: text)
+ .font(AppTypography.body)
+ .foregroundStyle(AppColors.textPrimary)
+ .padding(.horizontal, 12)
+ .frame(height: 50)
+ .background(AppColors.backgroundLight)
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .stroke(AppColors.secondary.opacity(0.2), lineWidth: 1)
+ )
+ }
+ }
+}
+#endif
diff --git a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
index d7b49bc..2e9a54c 100644
--- a/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
+++ b/pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift
@@ -1,3 +1,4 @@
+#if !os(Android)
import SwiftUI
#if canImport(PhotosUI) && os(iOS)
import PhotosUI
@@ -33,7 +34,7 @@ struct UserProfileView: View {
}
.background(AppColors.backgroundLight)
.navigationTitle("Meu Perfil")
- .navigationBarTitleDisplayMode(.inline)
+ .appInlineNavigationTitle()
.onAppear {
hydrateFromAppState()
}
@@ -267,3 +268,4 @@ struct UserProfileView: View {
}
#endif
}
+#endif
diff --git a/skip_for_android_plan.md b/skip_for_android_plan.md
new file mode 100644
index 0000000..3d08135
--- /dev/null
+++ b/skip_for_android_plan.md
@@ -0,0 +1,320 @@
+# Plano de Ação - Skip para Android
+
+## Objetivo
+
+Preparar o projeto `PediFoods` para o primeiro ciclo real de testes no Android, preservando integralmente o funcionamento atual do iOS.
+
+Regra principal deste plano:
+
+1. Todo ajuste necessário para Android deve ser isolado e não pode causar regressão no iOS.
+2. Sempre que possível, a separação deve ser feita com condicionais por plataforma, abstrações compatíveis ou implementações específicas para Android.
+3. Cada task concluída e validada deve gerar commit próprio.
+
+## Premissas confirmadas
+
+1. O projeto já possui base Skip dual-platform.
+2. O iOS está funcional e deve ser preservado como referência de comportamento.
+3. O Android já possui estrutura inicial pronta:
+ - `pedi-foods/Android/*`
+ - `pedi-foods/Android/app/src/main/kotlin/Main.kt`
+ - `pedi-foods/Android/app/src/main/AndroidManifest.xml`
+ - `pedi-foods/Sources/PediFoods/Skip/skip.yml`
+4. O objetivo imediato não é publicar Android, e sim conseguir compilar, instalar, abrir e validar os fluxos principais.
+
+## Diagnóstico atual
+
+### 1. Estrutura Android existente
+
+O projeto já possui:
+
+1. Manifest com permissões de internet e localização.
+2. `Main.kt` com tratamento inicial de permissão de localização no Android.
+3. Script dedicado para exportar e rodar no Android:
+ - `pedi-foods/scripts/android-run.sh`
+4. Recursos Android básicos como ícones launcher.
+
+### 2. Riscos identificados
+
+Os principais riscos para o primeiro teste Android são:
+
+1. Imports e dependências iOS-only ainda expostos em arquivos compartilhados.
+2. Build local atualmente bloqueado por problema de artefato/toolchain Skip.
+3. Recursos e comportamentos com paridade incompleta no Android.
+4. Cobertura de testes ainda insuficiente para confiar só em automação.
+
+### 3. Bloqueios técnicos já identificados
+
+#### 3.1. Código compartilhado com dependência iOS-only
+
+Foram encontrados pontos com alto risco para Android:
+
+1. `pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift`
+ - possui `import LCEssentials` sem blindagem de plataforma.
+2. `pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift`
+ - possui `import LCEssentials` sem blindagem de plataforma.
+3. Outros arquivos com dependências iOS-only exigem revisão cuidadosa:
+ - `pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift`
+ - `pedi-foods/Sources/PediFoods/Views/Main/ReviewsView.swift`
+ - `pedi-foods/Sources/PediFoods/Views/Main/UserProfileView.swift`
+ - `pedi-foods/Sources/PediFoods/Services/ApiClient.swift`
+
+#### 3.2. Ambiente/build Skip inconsistente
+
+Na validação local, o comando de build falhou antes da compilação funcional:
+
+1. `swift build` retornou erro de artefato ausente do Skip.
+2. O erro aponta para caminho antigo de projeto, indicando problema de cache, artefato ou configuração local do ambiente.
+
+Sem resolver isso, não é possível validar corretamente o Android.
+
+#### 3.3. Paridade Android ainda parcial
+
+Alguns comportamentos parecem estar deliberadamente simplificados para Android:
+
+1. Snackbar no Android está atualmente neutro/stubado.
+2. Splash nativo Android ainda não aparenta estar finalizado.
+3. Recursos visuais e alguns fluxos podem abrir, mas não com equivalência total ao iOS.
+
+Isso não impede o primeiro boot, mas entra como ajuste após a compilação inicial.
+
+## Estratégia de execução
+
+A execução deve seguir uma ordem rígida para evitar retrabalho:
+
+1. Primeiro remover bloqueios de compilação cross-platform.
+2. Depois estabilizar o ambiente Skip/Android local.
+3. Em seguida gerar build Android real.
+4. Depois validar execução no device/emulador.
+5. Só então ajustar bugs e diferenças de comportamento específicas do Android.
+6. Ao fim de cada etapa funcional concluída, gerar commit.
+
+## Plano de ação detalhado
+
+### Fase 1 - Blindagem do código compartilhado para Android
+
+Objetivo:
+Garantir que o código compartilhado consiga ser compilado para Android sem quebrar o iOS.
+
+Ações:
+
+1. Revisar todos os arquivos compartilhados com:
+ - `import LCEssentials`
+ - `import UIKit`
+ - `import PhotosUI`
+ - qualquer API não portável pelo Skip
+2. Encapsular uso iOS-only com:
+ - `#if os(iOS)`
+ - `#if canImport(...)`
+ - abstrações seguras por plataforma
+3. Confirmar que nenhum ajuste Android altere o comportamento já funcional do iOS.
+4. Validar se os fallbacks Android são suficientes para compilar.
+
+Critério de conclusão:
+
+1. O código compartilhado deixa de possuir bloqueios óbvios de compilação Android.
+2. As dependências iOS-only ficam totalmente isoladas.
+
+Commit ao concluir:
+
+1. Criar commit específico da blindagem cross-platform.
+
+### Fase 2 - Saneamento do ambiente Skip/Android
+
+Objetivo:
+Restabelecer o pipeline de build para que a validação Android seja real e repetível.
+
+Ações:
+
+1. Identificar a origem do erro de artefato do Skip.
+2. Verificar caches, artefatos e referências para caminho antigo do projeto.
+3. Corrigir ambiente local sem destruir configuração válida do iOS.
+4. Validar novamente:
+ - `swift build`
+ - fluxo de export Android via Skip
+
+Critério de conclusão:
+
+1. O build compartilhado executa sem erro de artefato.
+2. O projeto consegue iniciar a etapa de export Android.
+
+Commit ao concluir:
+
+1. Criar commit específico do ajuste de ambiente/configuração, se houver mudança versionada no repositório.
+2. Se a correção for apenas local e não versionável, registrar isso no andamento e seguir sem commit desta subparte.
+
+### Fase 3 - Primeiro build Android real
+
+Objetivo:
+Gerar APK/build Android funcional para teste.
+
+Ações:
+
+1. Rodar export/build Android pelo fluxo oficial do projeto.
+2. Validar geração dos artefatos Android esperados.
+3. Confirmar compatibilidade com ABI do emulador/device.
+4. Corrigir falhas de build especificamente Android sem alterar o caminho iOS.
+
+Critério de conclusão:
+
+1. APK ou build instalável gerado com sucesso.
+2. Nenhum erro de compilação Android pendente.
+
+Commit ao concluir:
+
+1. Criar commit específico do primeiro build Android estabilizado.
+
+### Fase 4 - Primeiro teste funcional no Android
+
+Objetivo:
+Instalar, abrir e validar o app no Android pela primeira vez.
+
+Ações:
+
+1. Instalar no emulador ou device.
+2. Validar abertura do app.
+3. Confirmar:
+ - splash inicial
+ - fluxo de login
+ - navegação principal por tabs
+ - home
+ - detalhe da loja
+ - perfil
+ - endereços
+4. Observar crashes, telas em branco, problemas de navegação ou layout quebrado.
+
+Critério de conclusão:
+
+1. O app abre no Android.
+2. Os fluxos principais navegam sem crash.
+
+Commit ao concluir:
+
+1. Criar commit específico do primeiro ciclo funcional de execução Android.
+
+### Fase 5 - Correções de runtime específicas do Android
+
+Objetivo:
+Ajustar o que só aparece após o app abrir no Android.
+
+Ações:
+
+1. Validar permissão de localização.
+2. Validar persistência/local cache usados pelo app no Android.
+3. Validar imagens e carregamento remoto.
+4. Validar teclado, foco e `adjustResize`.
+5. Validar sheets, modais, overlays e navegação.
+6. Corrigir bugs Android-only com isolamento por plataforma.
+
+Critério de conclusão:
+
+1. Os fluxos principais deixam de ter falhas críticas no Android.
+2. Nenhuma correção Android impacta o iOS.
+
+Commit ao concluir:
+
+1. Criar commits separados por grupo funcional concluído e validado.
+
+### Fase 6 - Paridade mínima de UX Android
+
+Objetivo:
+Atingir um nível mínimo aceitável de experiência para teste Android sem buscar perfeição visual prematura.
+
+Ações:
+
+1. Ajustar snackbar Android para comportamento visível e funcional.
+2. Revisar splash Android nativo, se necessário.
+3. Corrigir diferenças de layout mais críticas.
+4. Revisar componentes com comportamento visual diferente entre plataformas.
+
+Critério de conclusão:
+
+1. O Android fica funcional e utilizável para teste contínuo.
+2. As diferenças visuais restantes não impedem validação do produto.
+
+Commit ao concluir:
+
+1. Criar commit específico das melhorias de UX Android aprovadas.
+
+### Fase 7 - Regressão rápida no iOS
+
+Objetivo:
+Garantir que os ajustes Android não introduziram regressões no iOS.
+
+Ações:
+
+1. Rodar build iOS novamente.
+2. Validar navegação principal no iOS.
+3. Confirmar que os fluxos já funcionais continuam intactos.
+4. Revisar especialmente os pontos alterados com condicionais de plataforma.
+
+Critério de conclusão:
+
+1. iOS continua funcional após os ajustes Android.
+2. Nenhuma regressão relevante é detectada.
+
+Commit ao concluir:
+
+1. Criar commit final de consolidação apenas se houver mudanças adicionais nesta etapa.
+
+## Regras operacionais durante a execução
+
+1. Nenhum arquivo pode ultrapassar 500 linhas.
+2. Ajustes Android devem ser isolados e não devem reescrever a lógica funcional do iOS.
+3. Toda correção deve passar por validação antes de seguir para a próxima task.
+4. Cada task funcional concluída deve resultar em commit.
+5. Se uma task encontrar erro, ela retorna ao ciclo:
+ - identificar causa raiz
+ - corrigir
+ - testar novamente
+ - só então marcar como concluída
+
+## Convenção de commits
+
+Padrão recomendado:
+
+1. `fix(android): isolate ios-only dependencies for skip build`
+2. `fix(build): stabilize skip android export pipeline`
+3. `feat(android): first successful android build`
+4. `fix(android): resolve runtime issues on first boot`
+5. `feat(android): improve minimum ux parity`
+
+## Sequência de tasks para acompanhamento
+
+Legenda:
+
+- 🔴 Pendente
+- 🟢 Concluído
+
+1. 🟢 Revisar e blindar imports/dependências iOS-only no código compartilhado.
+2. 🟢 Corrigir o problema de artefato/caminho do ambiente Skip local.
+3. 🟢 Validar `swift build` sem erro de ambiente.
+4. 🟢 Validar export/build Android via Skip.
+5. 🟢 Gerar APK/build Android instalável.
+6. 🔴 Instalar e abrir o app no Android.
+7. 🔴 Validar login no Android.
+8. 🔴 Validar home e navegação principal no Android.
+9. 🔴 Validar detalhe de loja e carrinho no Android.
+10. 🔴 Validar perfil e endereços no Android.
+11. 🔴 Validar permissão e uso de localização no Android.
+12. 🔴 Corrigir bugs críticos de runtime Android.
+13. 🔴 Ajustar snackbar Android.
+14. 🔴 Revisar splash Android, se necessário.
+15. 🔴 Rodar regressão rápida no iOS após os ajustes Android.
+16. 🟢 Criar commit após cada task concluída e funcional.
+
+## Andamento
+
+1. Task 1 concluída e validada via build compartilhado.
+2. Task 2 concluída com ajuste local no cache/estado de build do Skip.
+ - Correção local não versionada.
+3. Task 3 concluída com `swift build` finalizado com sucesso.
+4. Task 4 concluída com `skip export --debug --no-ios --android --arch aarch64 --plain --verbose`.
+ - O `skip export` fechou com sucesso completo.
+ - O pipeline passou pela recompilação Swift compartilhada, geração Android Skip, `assembleDebug`, `bundleDebug` e export final.
+ - Artefatos gerados com sucesso em `pedi-foods/.build/skip-export/`:
+ - `PediFoods-debug.apk`
+ - `PediFoods-debug.aab`
+ - `PediFoods-project.zip`
+5. Task 5 concluída com geração validada de artefatos Android instaláveis.
+ - APK exportado com sucesso.
+ - AAB exportado com sucesso.
diff --git a/subdomain_stores_plan.md b/subdomain_stores_plan.md
new file mode 100644
index 0000000..26378fb
--- /dev/null
+++ b/subdomain_stores_plan.md
@@ -0,0 +1,744 @@
+# Subdomain Stores Plan
+
+## Objetivo
+
+Reorganizar o ecossistema do PediFoods para um modelo claro de subdomínios, mantendo a integração com o backend do Atomenta onde ela faz sentido, mas separando melhor identidade, navegação e contexto de cada área.
+
+## Cenário Desejado
+
+### 1. `pedifoods.com.br`
+
+Papel:
+- home pública
+- landing page
+- login
+- onboarding inicial
+- após login, exibir lista de lojas do usuário
+
+Responsabilidade:
+- identidade pública do PediFoods
+- autenticação do usuário final/parceiro
+- seleção da loja
+
+### 2. `parceiro.pedifoods.com.br`
+
+Papel:
+- painel do lojista
+- equivalente ao atual "Atomenta Store"
+- gestão operacional da loja
+
+Responsabilidade:
+- backoffice do parceiro
+- configuração da loja
+- cardápio
+- pedidos
+- financeiro
+- onboarding do parceiro
+
+### 3. `nome-curto-loja.pedifoods.com.br`
+
+Papel:
+- storefront da loja
+- vitrine pública ou semi-logada da loja
+- produtos, categorias, carrinho e experiência do cliente
+
+Responsabilidade:
+- experiência da loja individual
+- branding e contexto por loja
+- leitura do catálogo
+
+## Princípio Arquitetural
+
+O PediFoods não deve mais parecer "um Atomenta renomeado".
+
+Ele pode continuar:
+- consumindo APIs do Atomenta
+- reutilizando lógica de OTP
+- reutilizando cookies/tokens e integração segura quando necessário
+
+Mas deve deixar claro:
+- qual domínio é PediFoods
+- qual domínio é painel parceiro
+- qual domínio é storefront da loja
+
+## Direção Técnica
+
+### Separar 3 contextos
+
+1. `public app`
+- domínio base `pedifoods.com.br`
+- homepage, login, seleção de lojas
+
+2. `partner app`
+- subdomínio fixo `parceiro.pedifoods.com.br`
+- painel do lojista
+
+3. `store app`
+- subdomínio dinâmico `{slug}.pedifoods.com.br`
+- storefront da loja
+
+### Manter 1 base de código no curto prazo
+
+No curto prazo, o melhor custo-benefício é manter uma única codebase do PediFoods com roteamento por host.
+
+Ou seja:
+- o app lê o `Host`
+- identifica o contexto
+- renderiza a área correta
+
+Exemplo de resolução:
+- `pedifoods.com.br` => contexto `public`
+- `parceiro.pedifoods.com.br` => contexto `partner`
+- `slug.pedifoods.com.br` => contexto `store`
+
+Isso evita:
+- duplicação de projeto
+- deploy múltiplo desnecessário
+- divergência de regra de negócio
+
+## Regras de Resolução de Host
+
+### Contexto `public`
+
+Hosts:
+- `pedifoods.com.br`
+- `www.pedifoods.com.br` se existir
+
+Fluxos:
+- home
+- login
+- cadastro
+- recuperação de conta
+- listagem de lojas do usuário autenticado
+
+### Contexto `partner`
+
+Host:
+- `parceiro.pedifoods.com.br`
+
+Fluxos:
+- login do parceiro
+- dashboard da loja
+- onboarding
+- catálogo
+- pedidos
+- financeiro
+
+### Contexto `store`
+
+Host:
+- `{storeSlug}.pedifoods.com.br`
+
+Fluxos:
+- catálogo público da loja
+- detalhes da loja
+- carrinho
+- pedidos do cliente
+
+## Mudanças Estruturais Necessárias
+
+### 1. Resolver host de forma centralizada
+
+Criar uma camada única para:
+- ler `req.hostname`
+- classificar o contexto
+- extrair `storeSlug` quando aplicável
+
+Saída esperada:
+- `appContext.type = public | partner | store`
+- `appContext.storeSlug = string | null`
+
+### 2. Parar de usar defaults de domínio do Atomenta no PediFoods
+
+Hoje o PediFoods ainda carrega vários defaults como:
+- `https://atomenta.com.br`
+- `atomenta_token`
+- `atomenta_auth`
+- nomes e textos herdados
+
+Isso precisa ser separado em 2 grupos:
+
+#### Pode continuar apontando para Atomenta
+- integração de API interna
+- OTP/crypto
+- comunicação backend-to-backend
+- consulta de dados mestres, se esta for a arquitetura
+
+#### Não deve mais apontar para Atomenta
+- branding
+- domínio público
+- base URL de navegação do usuário
+- nomes do app
+- `package.json` e `ecosystem.config.js`
+
+### 3. Definir estratégia de sessão/cookies
+
+Decisão importante:
+
+O sistema terá **dois contextos de autenticação diferentes**:
+
+1. **partner session**
+- usada no `parceiro.pedifoods.com.br`
+- representa o lojista/operador
+- dá acesso ao painel administrativo
+
+2. **customer session**
+- usada em `pedifoods.com.br` e em `{slug}.pedifoods.com.br`
+- representa o cliente/comprador
+- dá acesso à navegação, seleção de lojas, carrinho e compra
+
+Essas duas sessões **não devem ser compartilhadas entre si**.
+
+Se fossem compartilhadas:
+- um usuário logado como parceiro poderia cair autenticado na storefront da própria loja
+- haveria risco de misturar permissões administrativas com experiência de compra
+- o modelo de segurança e UX ficaria ambíguo
+
+#### Opção A
+Sessão única compartilhada em `.pedifoods.com.br`
+
+Resultado:
+- mesma sessão valeria para:
+ - `pedifoods.com.br`
+ - `parceiro.pedifoods.com.br`
+ - `{slug}.pedifoods.com.br`
+
+Problema:
+- **não serve para este cenário**
+- mistura sessão de lojista com sessão de cliente
+
+#### Opção B
+Sessões separadas por contexto
+
+Modelo recomendado:
+- `parceiro.pedifoods.com.br`
+ - cookie próprio de parceiro
+ - escopo exclusivo do painel
+- `pedifoods.com.br`
+ - cookie próprio de cliente
+ - pode ser compartilhado com `{slug}.pedifoods.com.br` se fizer sentido para a jornada do comprador
+- `{slug}.pedifoods.com.br`
+ - usa sessão do cliente, nunca sessão do parceiro
+
+Vantagens:
+- separa claramente os perfis
+- evita vazamento de autenticação entre painel e loja
+- reduz risco de autorização indevida
+- deixa a jornada de compra independente da jornada administrativa
+
+Desvantagens:
+- exige mais cuidado no desenho de login/logout
+- exige nomes de cookies e middleware distintos
+
+Recomendação:
+- usar **sessões separadas por contexto**
+- nunca compartilhar sessão de `partner` com `customer`
+- se necessário, compartilhar apenas a sessão do **cliente** entre:
+ - `pedifoods.com.br`
+ - `{slug}.pedifoods.com.br`
+
+Implementação sugerida:
+- cookie de parceiro:
+ - exemplo `pedifoods_partner_session`
+ - domínio restrito a `parceiro.pedifoods.com.br`
+- cookie de cliente:
+ - exemplo `pedifoods_customer_session`
+ - domínio `.pedifoods.com.br` apenas se a jornada do cliente precisar navegar entre domínio principal e subdomínios de loja
+
+## Estratégia de Dados
+
+### Fonte de verdade
+
+Precisamos assumir explicitamente:
+- o Atomenta continua sendo a fonte de verdade operacional
+- o PediFoods atua como camada especializada de experiência/lojista/storefront
+
+Isso significa:
+- login e sessão podem existir no PediFoods
+- mas dados de loja, catálogo, usuário parceiro e regras sensíveis podem continuar vindo do Atomenta via API
+
+## Fases de Implementação
+
+## Fase 1. Higiene e identidade
+
+Objetivo:
+- remover confusão estrutural atual
+
+Itens:
+- corrigir `package.json` do PediFoods
+- corrigir `ecosystem.config.js`
+- revisar `env.example`
+- revisar nomes, textos e defaults públicos
+- separar claramente o que é branding PediFoods e o que é integração Atomenta
+
+Saída:
+- o projeto deixa de se identificar como Atomenta
+
+## Fase 2. Resolver host e contexto
+
+Objetivo:
+- introduzir leitura de subdomínio
+
+Itens:
+- criar utilitário central de hostname
+- classificar `public`, `partner`, `store`
+- disponibilizar contexto em middleware
+- ajustar templates/layouts e rotas para reagirem ao contexto
+
+Saída:
+- uma única app responde diferente por host
+
+## Fase 3. Fluxo público `pedifoods.com.br`
+
+Objetivo:
+- consolidar homepage + login + listagem de lojas
+
+Itens:
+- manter home atual
+- após login, mostrar lojas do usuário
+- ao clicar na loja, redirecionar para `slug.pedifoods.com.br`
+
+Saída:
+- domínio principal vira hub do usuário
+
+## Fase 4. Fluxo parceiro `parceiro.pedifoods.com.br`
+
+Objetivo:
+- isolar o backoffice do lojista
+
+Itens:
+- mover/ajustar as telas que hoje se comportam como "Atomenta Store"
+- garantir que esse host sempre abra painel parceiro
+- revisar permissões e middleware
+
+Saída:
+- painel do lojista com identidade própria
+
+## Fase 5. Fluxo loja `slug.pedifoods.com.br`
+
+Objetivo:
+- transformar storefront em domínio por loja
+
+Itens:
+- mapear slug -> loja
+- carregar tema, catálogo e dados por slug
+- revisar SEO/meta tags
+- revisar links absolutos
+
+Saída:
+- cada loja com seu subdomínio
+
+## Fase 6. Sessão compartilhada
+
+Objetivo:
+- permitir navegação fluida entre subdomínios
+
+Itens:
+- cookie domain `.pedifoods.com.br`
+- revisar login/logout
+- revisar redirecionamentos
+- revisar callbacks OTP
+
+Saída:
+- usuário loga uma vez e navega entre contextos compatíveis
+
+## Riscos
+
+### 1. Misturar branding com integração
+
+Se trocar tudo de `atomenta` cegamente, pode quebrar integrações reais.
+
+### 2. Cookies atuais
+
+Se hoje os cookies ainda usam nomes herdados, uma troca brusca pode invalidar sessões e OTP.
+
+### 3. Links absolutos hardcoded
+
+Há muitos pontos do PediFoods ainda apontando para Atomenta.
+Eles precisam ser classificados antes de alterar.
+
+### 4. Deploy atual
+
+Hoje o deploy sobe um projeto que ainda está semanticamente misturado.
+Sem limpeza mínima, a arquitetura nova vai nascer confusa.
+
+## Recomendações Práticas
+
+### Curto prazo
+
+- corrigir identidade mínima do projeto PediFoods
+- mapear todos os usos de:
+ - `atomenta.com.br`
+ - `atomenta_token`
+ - `atomenta_auth`
+ - `ATOMENTA_*`
+- classificar cada ocorrência em:
+ - branding
+ - integração legítima
+
+### Médio prazo
+
+- implementar middleware de contexto por host
+- separar layouts `public`, `partner`, `store`
+- começar pelo domínio `parceiro.pedifoods.com.br`
+
+### Longo prazo
+
+- consolidar storefront por slug
+- revisar autenticação multi-subdomínio
+- avaliar se no futuro compensa separar em múltiplos apps
+
+## Ordem Recomendada de Execução
+
+1. Higienizar identidade do projeto PediFoods
+2. Mapear referências herdadas de Atomenta
+3. Criar resolvedor central de host/subdomínio
+4. Subir `parceiro.pedifoods.com.br`
+5. Ajustar `pedifoods.com.br` para login + lista de lojas
+6. Implementar `slug.pedifoods.com.br`
+7. Revisar cookies e sessão compartilhada
+
+## Decisão Recomendada Agora
+
+A melhor decisão agora é:
+
+- **não tentar “desatomentar” tudo de uma vez**
+- primeiro separar:
+ - identidade pública
+ - painel parceiro
+ - storefront por subdomínio
+- e só depois revisar dependências herdadas uma a uma
+
+## Próximo Passo
+
+Próxima entrega recomendada:
+
+1. fazer uma auditoria do repositório PediFoods
+2. classificar referências herdadas em:
+ - `pode trocar agora`
+ - `depende de integração com Atomenta`
+3. implementar o resolvedor de subdomínio
+4. subir primeiro o contexto `parceiro.pedifoods.com.br`
+
+## Tasks de Implementação
+
+Legenda:
+- 🔴 pendente
+- 🟢 concluído
+
+### Fase 1. Higiene do projeto PediFoods
+
+- 🟢 Corrigir identidade básica do projeto em `package.json`
+- 🟢 Corrigir identidade básica do processo em `ecosystem.config.js`
+- 🔴 Revisar `env.example` para separar branding PediFoods de integração Atomenta
+- 🟢 Mapear todas as referências a `atomenta` no repositório
+- 🟢 Classificar cada referência como:
+ - branding/UI
+ - integração técnica legítima
+ - legado morto
+- 🟢 Remover ou ajustar branding incorreto do PediFoods
+
+### Fase 2. Contexto de domínio e subdomínio
+
+- 🟢 Criar utilitário central para resolver o host atual
+- 🟢 Criar classificador de contexto:
+ - `public`
+ - `partner`
+ - `store`
+- 🟢 Extrair `storeSlug` automaticamente quando o host for `{slug}.pedifoods.com.br`
+- 🟢 Injetar esse contexto em middleware global
+- 🟢 Expor o contexto para views/templates
+- 🔴 Garantir fallback seguro para domínio desconhecido
+
+### Fase 3. Sessões separadas
+
+- 🟢 Criar sessão de parceiro independente
+- 🔴 Criar sessão de cliente independente
+- 🔴 Garantir que `parceiro.pedifoods.com.br` nunca reutilize sessão de cliente
+- 🔴 Garantir que `{slug}.pedifoods.com.br` nunca reutilize sessão de parceiro
+- 🟢 Revisar nomes dos cookies
+- 🟢 Revisar `login`, `logout` e middleware de autenticação
+- 🟢 Revisar redirecionamentos pós-login por contexto
+
+### Fase 4. `pedifoods.com.br`
+
+- 🟢 Consolidar a home pública
+- 🟢 Consolidar login/cadastro/recuperação
+- 🔴 Criar tela pós-login com listagem de lojas do usuário
+- 🟢 Fazer o clique na loja redirecionar para `{slug}.pedifoods.com.br`
+- 🟢 Revisar meta tags, canonical e links absolutos
+
+### Fase 5. `parceiro.pedifoods.com.br`
+
+- 🟢 Isolar o painel do lojista nesse host
+- 🟢 Revisar middleware e permissões do painel
+- 🟢 Revisar links internos do painel para não mandar ao domínio errado
+- 🟢 Ajustar onboarding do parceiro
+- 🟢 Ajustar rotas administrativas de loja, pedidos, catálogo e financeiro
+- 🟢 Garantir que o painel não renderize a experiência pública da loja
+
+### Fase 6. `{slug}.pedifoods.com.br`
+
+- 🟢 Criar resolução da loja por slug
+- 🟢 Buscar loja e catálogo pelo slug
+- 🟢 Renderizar storefront por loja
+- 🟢 Ajustar tema, título, descrição e identidade da loja
+- 🟢 Ajustar links compartilháveis da loja
+- 🟢 Revisar SEO e OpenGraph por loja
+- 🔴 Garantir fallback para slug inexistente
+
+### Fase 7. Gestão de slug da loja
+
+- 🟢 Adicionar campo `storeSlug` como atributo persistido da loja
+- 🟢 Validar unicidade global do slug
+- 🟢 Validar formato do slug
+- 🟢 Gerar pré-preenchimento automático do slug no fim do onboarding com base no nome fantasia da loja
+- 🟢 Criar tela de configuração do slug no painel parceiro
+- 🟢 Exibir aviso de impacto da troca de slug
+- 🟢 Salvar data da última alteração de slug
+- 🟢 Bloquear nova alteração por 90 dias
+- 🟢 Exibir contador ou data de desbloqueio para nova alteração
+- 🔴 Criar redirect temporário do slug antigo para o novo, se viável
+
+### Fase 8. Integração com Atomenta
+
+- 🟢 Revisar quais chamadas devem continuar indo ao Atomenta
+- 🟢 Centralizar base URL interna da API do Atomenta
+- 🔴 Revisar OTP e autenticação compartilhada
+- 🟢 Revisar cookies herdados `atomenta_*`
+- 🔴 Revisar endpoints de catálogo, loja, financeiro e customer lookup
+- 🔴 Remover dependências herdadas desnecessárias
+
+### Fase 9. Deploy e infraestrutura
+
+- 🔴 Ajustar nginx/proxy para:
+ - `pedifoods.com.br`
+ - `parceiro.pedifoods.com.br`
+ - `*.pedifoods.com.br`
+- 🔴 Validar wildcard localmente antes da etapa de DNS público
+- 🔴 Garantir certificado TLS compatível com wildcard
+- 🔴 Validar resolução correta de host até a app Node
+
+## Status Atual
+
+Legenda:
+- 🟢 concluído
+- 🟡 parcial
+- 🔴 pendente
+
+Resumo do que já foi entregue no app:
+- 🟢 identidade básica do projeto corrigida
+- 🟢 resolução central de host/subdomínio
+- 🟢 separação inicial entre `public`, `partner` e `store`
+- 🟢 slug canônico com preview, unicidade, cooldown de 90 dias e geração automática no onboarding
+- 🟢 storefront canônica em `{slug}.pedifoods.com.br`
+- 🟢 links públicos e links do painel abrindo a loja publicada correta
+- 🟢 limpeza principal de branding visível do PediFoods
+- 🟢 frontend priorizando `pedifoods_partner_token` com fallback para legado
+
+O que ainda falta para fechar o fluxo completo:
+- 🔴 autenticação real do cliente
+- 🔴 tela pós-login do cliente com listagem de lojas
+- 🔴 fallback para slug inexistente
+- 🔴 redirect temporário de slug antigo para slug novo
+- 🔴 wildcard real em proxy/Cloudflare/TLS
+- 🔴 Validar deploy do PediFoods sem contaminar com Atomenta
+- 🔴 Configurar wildcard DNS no Cloudflare
+ - esta task deve ficar por último
+ - quando chegarmos nela, você executa manualmente no Cloudflare
+ - eu só vou te avisar e te passar o valor exato
+
+### Fase 10. Testes de ponta a ponta
+
+- 🔴 Testar login de cliente em `pedifoods.com.br`
+- 🔴 Testar seleção de loja e redirecionamento para `{slug}.pedifoods.com.br`
+- 🔴 Testar login de parceiro em `parceiro.pedifoods.com.br`
+- 🔴 Testar isolamento entre sessão de parceiro e sessão de cliente
+- 🔴 Testar troca de slug com bloqueio de 90 dias
+- 🔴 Testar slug inválido/inexistente
+- 🔴 Testar logout em cada contexto
+- 🔴 Testar links absolutos, redirects e cookies
+
+## Política de Slug da Loja
+
+Sua ideia funciona e faz sentido.
+
+### Recomendação
+
+O slug deve ser:
+- criado uma primeira vez no onboarding da loja
+- editável pelo parceiro no painel
+- bloqueado por 90 dias após cada alteração
+
+### Motivos para essa regra
+
+- evita troca frequente de URL
+- reduz confusão para clientes recorrentes
+- protege links já compartilhados
+- reduz impacto operacional em cache, indexing e hábito do usuário
+
+### Como funcionaria
+
+Cada loja teria campos como:
+- `storeSlug`
+- `slugUpdatedAt`
+- `slugLockedUntil`
+- `slugGeneratedBySystemAt`
+- `slugFirstManualUpdateAt`
+
+Regra:
+- se nunca definiu slug:
+ - o sistema pré-preenche automaticamente
+ - o usuário pode ajustar antes de concluir
+- se já definiu:
+ - só pode alterar novamente após 90 dias
+
+### Geração inicial do slug
+
+Recomendação:
+- ao terminar o onboarding, gerar um pré-preenchimento automático baseado no nome fantasia
+
+Exemplo:
+- `Pizzaria do Zé` -> `pizzaria-do-ze`
+- `Temaki House Prime` -> `temaki-house-prime`
+
+O usuário:
+- vê o slug sugerido
+- pode editar antes de salvar pela primeira vez
+
+Importante:
+- o slug gerado automaticamente pelo sistema **não inicia** a janela de bloqueio de 90 dias
+- ele serve apenas como valor inicial/sugestão persistida
+- o contador de 90 dias só começa quando houver a **primeira alteração manual do lojista**
+
+### Limite de tamanho
+
+Recomendação:
+- limitar o slug a **30 caracteres**
+
+Esse limite deve contar:
+- letras
+- números
+- hífens
+
+Na prática:
+- espaços viram hífen
+- então o total final do slug salvo não pode passar de `30`
+
+Exemplo de regra:
+- nome fantasia muito grande:
+ - `Loja Muito Grande Com Nome Enorme da Silva`
+- slug gerado:
+ - `loja-muito-grande-com-nome`
+
+### Regra de sanitização sugerida
+
+O gerador automático deve:
+- converter para minúsculas
+- remover acentos
+- trocar espaços por hífen
+- remover caracteres especiais
+- colapsar hífens duplicados
+- cortar em `30` caracteres
+- remover hífen do início/fim
+
+### Se o slug já existir
+
+Se o slug gerado já estiver em uso:
+- adicionar sufixo incremental curto
+
+Exemplo:
+- `pizzaria-do-ze`
+- `pizzaria-do-ze-2`
+- `pizzaria-do-ze-3`
+
+Sempre respeitando o limite final de `30` caracteres
+
+### Regra de bloqueio de 90 dias
+
+Regra correta:
+
+- **geração automática pelo sistema**
+ - não conta como alteração manual
+ - não bloqueia o campo
+
+- **primeira edição manual feita pelo lojista**
+ - passa a contar como alteração real
+ - grava `slugFirstManualUpdateAt`
+ - grava `slugUpdatedAt`
+ - define `slugLockedUntil = slugUpdatedAt + 90 dias`
+
+- **edições seguintes**
+ - só permitidas após `slugLockedUntil`
+
+### Exemplo prático
+
+1. onboarding termina
+2. sistema gera:
+ - `pizzaria-do-ze`
+3. lojista vê o valor e troca para:
+ - `pizza-do-ze-centro`
+4. essa troca manual:
+ - conta como primeira alteração real
+ - inicia a trava de 90 dias
+
+Se o lojista aceitar o slug automático sem mexer:
+- nenhum bloqueio de 90 dias deve ser iniciado
+- ele ainda pode fazer a primeira alteração manual depois
+
+### Ajuste das tasks
+
+- 🔴 Persistir metadado informando se o slug atual foi gerado automaticamente ou definido manualmente
+- 🔴 Garantir que a geração automática no onboarding não inicie `slugLockedUntil`
+- 🔴 Iniciar a janela de 90 dias somente na primeira alteração manual do lojista
+
+### Validações necessárias
+
+- slug único no sistema
+- somente letras minúsculas, números e hífen
+- tamanho mínimo e máximo
+- blacklist de reservados, por exemplo:
+ - `www`
+ - `api`
+ - `admin`
+ - `app`
+ - `parceiro`
+ - `mail`
+ - `git`
+ - `proxy`
+
+### Redirect do slug antigo
+
+Se possível, é recomendável:
+- guardar o slug antigo
+- fazer redirect `301` do slug antigo para o novo por um período
+
+Isso ajuda:
+- SEO
+- links salvos
+- usuários acostumados com a URL antiga
+
+### Sobre DNS e Cloudflare
+
+Isso funciona bem se você usar:
+- wildcard DNS `*.pedifoods.com.br`
+- proxy reverso lendo o `Host`
+
+Nesse modelo:
+- você **não precisa criar um registro DNS novo por loja**
+- o wildcard cobre todos os slugs
+- a troca de slug depende mais da aplicação e do cache HTTP do que de propagação DNS individual
+
+Ou seja:
+- a trava de 90 dias continua fazendo sentido por produto e UX
+- mas não por limitação técnica de DNS em si
+
+### Recomendação final para slug
+
+Implementar assim:
+
+- slug definido no onboarding
+- edição permitida no painel parceiro
+- nova alteração somente após 90 dias
+- aviso visual claro antes de salvar
+- registro do slug antigo para redirect temporário