scroll stick
This commit is contained in:
@@ -32,11 +32,10 @@ struct FeaturedStoreCard: View {
|
|||||||
.padding(10)
|
.padding(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(store.name)
|
Text(store.name)
|
||||||
.font(AppTypography.heading3)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
.lineLimit(1)
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "star.fill")
|
Image(systemName: "star.fill")
|
||||||
@@ -129,6 +128,8 @@ struct FeaturedStoreCardModel: Identifiable {
|
|||||||
let isFavorite: Bool
|
let isFavorite: Bool
|
||||||
let iconName: String
|
let iconName: String
|
||||||
let imageURL: String?
|
let imageURL: String?
|
||||||
|
let logoURL: String?
|
||||||
|
let coverURL: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SpecialOfferCard: View {
|
struct SpecialOfferCard: View {
|
||||||
|
|||||||
@@ -72,6 +72,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Adicionais" : {
|
||||||
|
"comment" : "A heading for the additional options available for a product.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Adicionar novo endereço" : {
|
"Adicionar novo endereço" : {
|
||||||
"comment" : "A button label that translates to \"Add new address\" in English.",
|
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -156,6 +160,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Cardápio indisponível no momento." : {
|
||||||
|
"comment" : "A message displayed when a store's menu is unavailable.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Carregando sua sessão..." : {
|
"Carregando sua sessão..." : {
|
||||||
"comment" : "A loading message displayed while bootstrapping the user's session.",
|
"comment" : "A loading message displayed while bootstrapping the user's session.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -264,6 +272,10 @@
|
|||||||
"comment" : "A welcome message displayed on the login screen.",
|
"comment" : "A welcome message displayed on the login screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Detalhes" : {
|
||||||
|
"comment" : "The title of the navigation bar at the top of the product detail sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"DL" : {
|
"DL" : {
|
||||||
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
|
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -310,6 +322,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Fechar" : {
|
||||||
|
"comment" : "A button to close the current view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Filtros" : {
|
"Filtros" : {
|
||||||
"comment" : "A label for the filter options in the modal.",
|
"comment" : "A label for the filter options in the modal.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -381,6 +397,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"inset %@" : {
|
||||||
|
"comment" : "A label displaying the amount by which the category tabs are inset when they are pinned to the top of the view. The argument is the string “inf”, the string “max+” or the string “min-”.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"inset %lld" : {
|
||||||
|
"comment" : "A label showing the amount of space between the bottom of the category tabs and the top of the viewport.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Insira o código de 8 dígitos enviado" : {
|
"Insira o código de 8 dígitos enviado" : {
|
||||||
"comment" : "A description below the text field where the user inputs their OTP code.",
|
"comment" : "A description below the text field where the user inputs their OTP code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -512,6 +536,10 @@
|
|||||||
},
|
},
|
||||||
"O que vai querer pedir hoje?\n " : {
|
"O que vai querer pedir hoje?\n " : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"offset %@" : {
|
||||||
|
"comment" : "A label displaying the current scroll offset, useful for debugging The argument is the string “inf”, the string “max+” or the string “min-”.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"para %@" : {
|
"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.",
|
"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.",
|
||||||
@@ -529,6 +557,10 @@
|
|||||||
"comment" : "A label for the \"Profile\" tab in the main tab view.",
|
"comment" : "A label for the \"Profile\" tab in the main tab view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"pin %lld" : {
|
||||||
|
"comment" : "A label showing whether the category tabs are pinned or not. The argument is a boolean value (`true` if pinned, `false` otherwise)",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Política de Privacidade" : {
|
"Política de Privacidade" : {
|
||||||
"comment" : "The title of the privacy policy section.",
|
"comment" : "The title of the privacy policy section.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -587,6 +619,17 @@
|
|||||||
"comment" : "A button that allows a user to request a new OTP code.",
|
"comment" : "A button that allows a user to request a new OTP code.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"RESTAURANT" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"safe %@" : {
|
||||||
|
"comment" : "A debug label showing the safe area inset value. The argument is the string “inf”, the string “max+” or the string “min-”.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"safe %lld" : {
|
||||||
|
"comment" : "A text element displaying the safe area inset at the top of the view",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Save" : {
|
"Save" : {
|
||||||
"comment" : "Button title indicating that the current contents should be saved",
|
"comment" : "Button title indicating that the current contents should be saved",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -689,6 +732,10 @@
|
|||||||
"comment" : "A link to the app's \"Terms of Use\".",
|
"comment" : "A link to the app's \"Terms of Use\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"th %@" : {
|
||||||
|
"comment" : "A label displaying the threshold at which the category tabs should become pinned. The argument is the string “inf”, the string “max+” or the string “min-”.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Title" : {
|
"Title" : {
|
||||||
"comment" : "Label for the item editor form indicating the title of the item",
|
"comment" : "Label for the item editor form indicating the title of the item",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|||||||
@@ -179,6 +179,16 @@ final class ApiService {
|
|||||||
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
|
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items)
|
||||||
return try await sendEnvelope(req)
|
return try await sendEnvelope(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func storeInfo(storeId: String) async throws -> ApiEnvelope<StoreInfoResult> {
|
||||||
|
let req = ApiRequest(path: "/api/store/\(storeId)/info", method: "GET", module: .store, requiresAuth: true)
|
||||||
|
return try await sendEnvelope(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func storeCatalog(storeId: String) async throws -> ApiEnvelope<[StoreCatalogCategory]> {
|
||||||
|
let req = ApiRequest(path: "/api/store/\(storeId)/catalog", method: "GET", module: .store, requiresAuth: true)
|
||||||
|
return try await sendEnvelope(req)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - DTOs
|
// MARK: - DTOs
|
||||||
@@ -254,6 +264,175 @@ struct StoreSummary: Decodable {
|
|||||||
let statusLabel: String?
|
let statusLabel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct StoreInfoResult: Decodable {
|
||||||
|
let isOpen: Bool?
|
||||||
|
let statusLabel: String?
|
||||||
|
let deliveryTime: String?
|
||||||
|
let minOrder: Double?
|
||||||
|
let address: StoreAddressInfo?
|
||||||
|
let paymentMethods: StorePaymentMethodsInfo?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case isOpen
|
||||||
|
case statusLabel
|
||||||
|
case deliveryTime
|
||||||
|
case minOrder
|
||||||
|
case address
|
||||||
|
case paymentMethods
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
isOpen = try? container.decode(Bool.self, forKey: .isOpen)
|
||||||
|
statusLabel = try? container.decode(String.self, forKey: .statusLabel)
|
||||||
|
deliveryTime = try? container.decode(String.self, forKey: .deliveryTime)
|
||||||
|
minOrder = ApiService.decodeFlexibleDouble(from: container, keys: [.minOrder])
|
||||||
|
address = try? container.decode(StoreAddressInfo.self, forKey: .address)
|
||||||
|
paymentMethods = try? container.decode(StorePaymentMethodsInfo.self, forKey: .paymentMethods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreAddressInfo: Decodable {
|
||||||
|
let street: String?
|
||||||
|
let number: String?
|
||||||
|
let neighborhood: String?
|
||||||
|
let city: String?
|
||||||
|
let state: String?
|
||||||
|
let latitude: Double?
|
||||||
|
let longitude: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case street
|
||||||
|
case number
|
||||||
|
case neighborhood
|
||||||
|
case city
|
||||||
|
case state
|
||||||
|
case latitude
|
||||||
|
case longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
street = try? container.decode(String.self, forKey: .street)
|
||||||
|
number = try? container.decode(String.self, forKey: .number)
|
||||||
|
neighborhood = try? container.decode(String.self, forKey: .neighborhood)
|
||||||
|
city = try? container.decode(String.self, forKey: .city)
|
||||||
|
state = try? container.decode(String.self, forKey: .state)
|
||||||
|
latitude = ApiService.decodeFlexibleDouble(from: container, keys: [.latitude])
|
||||||
|
longitude = ApiService.decodeFlexibleDouble(from: container, keys: [.longitude])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StorePaymentMethodsInfo: Decodable {
|
||||||
|
let acceptPix: Bool?
|
||||||
|
let acceptCash: Bool?
|
||||||
|
let acceptCreditCard: Bool?
|
||||||
|
let acceptDebitCard: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreCatalogCategory: Decodable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let products: [StoreCatalogProduct]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case products
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||||
|
name = (try? container.decode(String.self, forKey: .name)) ?? "Categoria"
|
||||||
|
products = (try? container.decode([StoreCatalogProduct].self, forKey: .products)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreCatalogProduct: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let description: String?
|
||||||
|
let image: String?
|
||||||
|
let price: Double?
|
||||||
|
let originalPrice: Double?
|
||||||
|
let addonGroups: [StoreAddonGroup]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case description
|
||||||
|
case desc
|
||||||
|
case image
|
||||||
|
case cover
|
||||||
|
case photo
|
||||||
|
case price
|
||||||
|
case originalPrice
|
||||||
|
case oldPrice
|
||||||
|
case addonGroups
|
||||||
|
case addons
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||||
|
name = (try? container.decode(String.self, forKey: .name)) ?? "Produto"
|
||||||
|
description = (try? container.decode(String.self, forKey: .description)) ?? (try? container.decode(String.self, forKey: .desc))
|
||||||
|
image = (try? container.decode(String.self, forKey: .image))
|
||||||
|
?? (try? container.decode(String.self, forKey: .cover))
|
||||||
|
?? (try? container.decode(String.self, forKey: .photo))
|
||||||
|
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
|
||||||
|
originalPrice = ApiService.decodeFlexibleDouble(from: container, keys: [.originalPrice, .oldPrice])
|
||||||
|
addonGroups = (try? container.decode([StoreAddonGroup].self, forKey: .addonGroups))
|
||||||
|
?? (try? container.decode([StoreAddonGroup].self, forKey: .addons))
|
||||||
|
?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreAddonGroup: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let minSelectors: Int?
|
||||||
|
let maxSelectors: Int?
|
||||||
|
let items: [StoreAddonItem]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case minSelectors
|
||||||
|
case maxSelectors
|
||||||
|
case items
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||||
|
name = (try? container.decode(String.self, forKey: .name)) ?? "Adicionais"
|
||||||
|
minSelectors = try? container.decode(Int.self, forKey: .minSelectors)
|
||||||
|
maxSelectors = try? container.decode(Int.self, forKey: .maxSelectors)
|
||||||
|
items = (try? container.decode([StoreAddonItem].self, forKey: .items)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StoreAddonItem: Decodable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let price: Double?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case price
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = (try? container.decode(String.self, forKey: .id)) ?? UUID().uuidString
|
||||||
|
name = (try? container.decode(String.self, forKey: .name)) ?? "Item"
|
||||||
|
price = ApiService.decodeFlexibleDouble(from: container, keys: [.price])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct PublicCategory: Decodable {
|
struct PublicCategory: Decodable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
@@ -422,3 +601,25 @@ struct CepLookupResult: Decodable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension ApiService {
|
||||||
|
static func decodeFlexibleDouble<K: CodingKey>(from container: KeyedDecodingContainer<K>, keys: [K]) -> Double? {
|
||||||
|
for key in keys {
|
||||||
|
if let value = try? container.decode(Double.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let asInt = try? container.decode(Int.self, forKey: key) {
|
||||||
|
return Double(asInt)
|
||||||
|
}
|
||||||
|
if let asString = try? container.decode(String.self, forKey: key) {
|
||||||
|
let normalized = asString
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.replacingOccurrences(of: ",", with: ".")
|
||||||
|
if let parsed = Double(normalized) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,8 +83,21 @@ struct HomeView: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
ForEach(featuredStoresCards) { store in
|
ForEach(featuredStoresCards) { store in
|
||||||
FeaturedStoreCard(store: store)
|
NavigationLink {
|
||||||
.frame(width: 190)
|
StoreDetailView(
|
||||||
|
storeId: store.id,
|
||||||
|
storeName: store.name,
|
||||||
|
storeCoverURL: store.coverURL,
|
||||||
|
storeLogoURL: store.logoURL,
|
||||||
|
storeCategory: store.category,
|
||||||
|
storeRating: store.rating,
|
||||||
|
storeDistance: store.distance
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
FeaturedStoreCard(store: store)
|
||||||
|
.frame(width: 190)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -138,7 +151,20 @@ struct HomeView: View {
|
|||||||
} else {
|
} else {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ForEach(nearbyStoreCards) { store in
|
ForEach(nearbyStoreCards) { store in
|
||||||
FeaturedStoreCard(store: store)
|
NavigationLink {
|
||||||
|
StoreDetailView(
|
||||||
|
storeId: store.id,
|
||||||
|
storeName: store.name,
|
||||||
|
storeCoverURL: store.coverURL,
|
||||||
|
storeLogoURL: store.logoURL,
|
||||||
|
storeCategory: store.category,
|
||||||
|
storeRating: store.rating,
|
||||||
|
storeDistance: store.distance
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
FeaturedStoreCard(store: store)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -296,7 +322,9 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
|
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
|
||||||
FeaturedStoreCardModel(
|
let coverURL = resolveStoreMediaURL(store.cover)
|
||||||
|
let logoURL = resolveStoreMediaURL(store.logo)
|
||||||
|
return FeaturedStoreCardModel(
|
||||||
id: store.id,
|
id: store.id,
|
||||||
name: store.name,
|
name: store.name,
|
||||||
rating: store.rating ?? 0,
|
rating: store.rating ?? 0,
|
||||||
@@ -306,16 +334,15 @@ struct HomeView: View {
|
|||||||
promoText: nil,
|
promoText: nil,
|
||||||
isFavorite: appState.favorites.storeIds.contains(store.id),
|
isFavorite: appState.favorites.storeIds.contains(store.id),
|
||||||
iconName: "storefront",
|
iconName: "storefront",
|
||||||
imageURL: resolveStoreImageURL(logo: store.logo, cover: store.cover)
|
imageURL: coverURL ?? logoURL,
|
||||||
|
logoURL: logoURL,
|
||||||
|
coverURL: coverURL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveStoreImageURL(logo: String?, cover: String?) -> String? {
|
private func resolveStoreMediaURL(_ raw: String?) -> String? {
|
||||||
let preferred = [logo, cover]
|
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
raw.isEmpty == false else { return nil }
|
||||||
.first { $0.isEmpty == false }
|
|
||||||
|
|
||||||
guard let raw = preferred else { return nil }
|
|
||||||
|
|
||||||
if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
|
if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
|
||||||
return raw
|
return raw
|
||||||
|
|||||||
755
pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift
Normal file
755
pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import LCEssentials
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct StoreDetailView: View {
|
||||||
|
let storeId: String
|
||||||
|
let storeName: String
|
||||||
|
let storeCoverURL: String?
|
||||||
|
let storeLogoURL: String?
|
||||||
|
let storeCategory: String?
|
||||||
|
let storeRating: Double?
|
||||||
|
let storeDistance: String?
|
||||||
|
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
@State var isLoading = true
|
||||||
|
@State var errorMessage: String? = nil
|
||||||
|
@State var info: StoreInfoResult? = nil
|
||||||
|
@State var categories: [StoreCatalogCategory] = []
|
||||||
|
@State var selectedCategoryId: String? = nil
|
||||||
|
@State var selectedProduct: StoreCatalogProduct? = nil
|
||||||
|
@State var didLoad = false
|
||||||
|
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||||
|
@State var isProgrammaticCategoryScroll = false
|
||||||
|
@State var scrollOffsetY: CGFloat = 0
|
||||||
|
|
||||||
|
let cardTopInset: CGFloat = 168
|
||||||
|
let summaryCardHeight: CGFloat = 170
|
||||||
|
let coverVisibleUntilY: CGFloat = 253
|
||||||
|
let storeLogoSize: CGFloat = 84
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let safeTop = geometry.safeAreaInsets.top
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
AppColors.backgroundLight.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
topSection
|
||||||
|
|
||||||
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||||
|
|
||||||
|
sectionedProducts
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if isCategoryTabsPinned(safeTop: safeTop) {
|
||||||
|
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: true)
|
||||||
|
.transition(.opacity)
|
||||||
|
.zIndex(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text("safe \(debugNumber(safeTop))")
|
||||||
|
Text("offset \(debugNumber(scrollOffsetY))")
|
||||||
|
Text("th \(debugNumber(categoryTabsPinThreshold(safeTop: safeTop)))")
|
||||||
|
Text("pin \(isCategoryTabsPinned(safeTop: safeTop) ? 1 : 0)")
|
||||||
|
Text("inset \(debugNumber(categoryTabsPinnedInset(safeTop: safeTop)))")
|
||||||
|
}
|
||||||
|
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.black.opacity(0.7))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
.padding(.top, safeTop + 6)
|
||||||
|
.padding(.trailing, 8)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.toolbarBackground(.hidden, for: .navigationBar)
|
||||||
|
.task {
|
||||||
|
guard didLoad == false else { return }
|
||||||
|
didLoad = true
|
||||||
|
await loadStoreData()
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedProduct) { product in
|
||||||
|
NavigationStack {
|
||||||
|
ProductDetailSheet(product: product, imageURL: resolvedURL(product.image))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onPreferenceChange(CategoryHeaderOffsetPreferenceKey.self) { offsets in
|
||||||
|
categoryHeaderOffsets = offsets
|
||||||
|
syncCategoryWithScroll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topSection: some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
heroSection
|
||||||
|
.frame(height: topSectionHeight)
|
||||||
|
|
||||||
|
// Hard cut: cover cannot appear below this line.
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppColors.backgroundLight)
|
||||||
|
.frame(height: max(0, topSectionHeight - coverVisibleUntilY))
|
||||||
|
.offset(y: coverVisibleUntilY)
|
||||||
|
|
||||||
|
summaryCard
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, cardTopInset)
|
||||||
|
|
||||||
|
storeLogoBadge
|
||||||
|
.padding(.top, cardTopInset - (storeLogoSize / 2))
|
||||||
|
}
|
||||||
|
.frame(height: topSectionHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heroSection: some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
AsyncStoreImage(imageURL: resolvedURL(storeCoverURL))
|
||||||
|
.frame(height: topSectionHeight)
|
||||||
|
.overlay(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.black.opacity(0.32), Color.black.opacity(0.05)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
heroIconButton(icon: "chevron.left") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
heroIconButton(icon: "magnifyingglass") {}
|
||||||
|
heroIconButton(icon: "heart") {}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.top, UIDevice.topNotch)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("RESTAURANT")
|
||||||
|
.font(AppTypography.overline)
|
||||||
|
.tracking(1.8)
|
||||||
|
.foregroundStyle(Color.white.opacity(0.92))
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storeLogoBadge: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(AppColors.surface)
|
||||||
|
.frame(width: storeLogoSize, height: storeLogoSize)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white, lineWidth: 5)
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncStoreImage(imageURL: resolvedURL(storeLogoURL))
|
||||||
|
.frame(width: storeLogoSize - 10, height: storeLogoSize - 10)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.shadow(color: Color.black.opacity(0.10), radius: 8, y: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var summaryCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(storeName)
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.padding(.top, 30)
|
||||||
|
|
||||||
|
Text(storeSubtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ratingChip
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
statItem(title: "TEMPO", value: info?.deliveryTime ?? "25-35 min")
|
||||||
|
Divider().frame(height: 34)
|
||||||
|
statItem(title: "ENTREGA", value: deliveryValueLabel)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.frame(height: summaryCardHeight)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||||
|
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sectionedProducts: some View {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.top, 26)
|
||||||
|
} else if let errorMessage {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
Button("Tentar novamente") {
|
||||||
|
Task { await loadStoreData() }
|
||||||
|
}
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
} else if categories.isEmpty {
|
||||||
|
Text("Cardápio indisponível no momento.")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.padding(.top, 16)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 26) {
|
||||||
|
ForEach(categories, id: \.id) { category in
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(category.name)
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.id(sectionAnchorId(for: category.id))
|
||||||
|
.background(
|
||||||
|
GeometryReader { geometry in
|
||||||
|
Color.clear.preference(
|
||||||
|
key: CategoryHeaderOffsetPreferenceKey.self,
|
||||||
|
value: [category.id: geometry.frame(in: .global).minY]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(category.products) { product in
|
||||||
|
productCard(product)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoryTabs(proxy: ScrollViewProxy, safeTop: CGFloat, isSticky: Bool) -> some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(categories, id: \.id) { category in
|
||||||
|
let active = selectedCategoryId == category.id
|
||||||
|
Button {
|
||||||
|
selectedCategoryId = category.id
|
||||||
|
isProgrammaticCategoryScroll = true
|
||||||
|
withAnimation(.easeInOut(duration: 0.25)) {
|
||||||
|
proxy.scrollTo(sectionAnchorId(for: category.id), anchor: .top)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||||
|
isProgrammaticCategoryScroll = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(category.name)
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(active ? AppColors.textInverse : AppColors.textMuted)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(active ? AppColors.primary : AppColors.surface)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.padding(.top, isSticky ? categoryTabsPinnedInset(safeTop: safeTop) : 10)
|
||||||
|
}
|
||||||
|
.background(AppColors.backgroundLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCategoryTabsPinned(safeTop: CGFloat) -> Bool {
|
||||||
|
scrollOffsetY >= categoryTabsPinThreshold(safeTop: safeTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoryTabsPinThreshold(safeTop: CGFloat) -> CGFloat {
|
||||||
|
max(0, topSectionHeight + 10 - safeTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoryTabsPinnedInset(safeTop: CGFloat) -> CGFloat {
|
||||||
|
max(20, safeTop + 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func debugNumber(_ value: CGFloat) -> String {
|
||||||
|
if value.isFinite == false {
|
||||||
|
return "inf"
|
||||||
|
}
|
||||||
|
if value > CGFloat(Int.max) {
|
||||||
|
return "max+"
|
||||||
|
}
|
||||||
|
if value < CGFloat(Int.min) {
|
||||||
|
return "min-"
|
||||||
|
}
|
||||||
|
return String(Int(value.rounded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func productCard(_ product: StoreCatalogProduct) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(product.name)
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
if let description = product.description, description.isEmpty == false {
|
||||||
|
Text(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(formatCurrency(product.price))
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
AsyncStoreImage(imageURL: resolvedURL(product.image))
|
||||||
|
.frame(width: 92, height: 92)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
|
||||||
|
Button {
|
||||||
|
selectedProduct = product
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.background(AppColors.tertiary)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.offset(x: 7, y: 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.background(Color.white.opacity(0.24))
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statItem(title: String, value: String) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(AppTypography.caption)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
Text(value)
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var ratingChip: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color(hex: "#F5B335"))
|
||||||
|
Text(String(format: "%.1f", storeRating ?? 0))
|
||||||
|
.font(AppTypography.caption)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(AppColors.brandSoft)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var storeSubtitle: String {
|
||||||
|
let category = (storeCategory ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if category.isEmpty { return "Restaurant" }
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deliveryValueLabel: String {
|
||||||
|
if let minOrder = info?.minOrder {
|
||||||
|
return formatCurrency(minOrder)
|
||||||
|
}
|
||||||
|
if let storeDistance, storeDistance.isEmpty == false {
|
||||||
|
return storeDistance
|
||||||
|
}
|
||||||
|
return "R$ --"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadStoreData() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
async let infoRequest = ApiService().storeInfo(storeId: storeId)
|
||||||
|
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
|
||||||
|
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
|
||||||
|
|
||||||
|
if infoResponse.error {
|
||||||
|
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if catalogResponse.error {
|
||||||
|
errorMessage = catalogResponse.message ?? "Não foi possível carregar o catálogo."
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info = infoResponse.result
|
||||||
|
categories = catalogResponse.result ?? []
|
||||||
|
selectedCategoryId = categories.first?.id
|
||||||
|
isLoading = false
|
||||||
|
} catch {
|
||||||
|
isLoading = false
|
||||||
|
if let network = error as? NetworkError {
|
||||||
|
errorMessage = network.errorDescription ?? "Erro ao carregar loja."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let service = error as? ApiServiceError {
|
||||||
|
errorMessage = service.errorDescription ?? "Erro ao carregar loja."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
errorMessage = "Erro ao carregar loja."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedURL(_ raw: String?) -> String? {
|
||||||
|
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
raw.isEmpty == false else { return nil }
|
||||||
|
|
||||||
|
let lower = raw.lowercased()
|
||||||
|
if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
let base = ApiConfig.baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
let path = raw.hasPrefix("/") ? raw : "/\(raw)"
|
||||||
|
return "\(base)\(path)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatCurrency(_ value: Double?) -> String {
|
||||||
|
guard let value else { return "R$ --" }
|
||||||
|
return String(format: "R$ %.2f", value).replacingOccurrences(of: ".", with: ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var topSectionHeight: CGFloat {
|
||||||
|
cardTopInset + summaryCardHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionAnchorId(for categoryId: String) -> String {
|
||||||
|
"category-section-\(categoryId)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncCategoryWithScroll() {
|
||||||
|
guard isLoading == false else { return }
|
||||||
|
guard isProgrammaticCategoryScroll == false else { return }
|
||||||
|
guard categoryHeaderOffsets.isEmpty == false else { return }
|
||||||
|
|
||||||
|
// Section whose header is nearest to the top content area wins.
|
||||||
|
let topThreshold: CGFloat = 180
|
||||||
|
let sorted = categoryHeaderOffsets.sorted { $0.value < $1.value }
|
||||||
|
|
||||||
|
if let current = sorted.last(where: { $0.value <= topThreshold })?.key {
|
||||||
|
selectedCategoryId = current
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let firstVisible = sorted.first?.key {
|
||||||
|
selectedCategoryId = firstVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||||
|
static let defaultValue: [String: CGFloat] = [:]
|
||||||
|
|
||||||
|
static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
|
||||||
|
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScrollOffsetReader: View {
|
||||||
|
@Binding var offsetY: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if canImport(UIKit)
|
||||||
|
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
|
||||||
|
#else
|
||||||
|
Color.clear
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
struct ScrollOffsetReaderRepresentable: UIViewRepresentable {
|
||||||
|
@Binding var offsetY: CGFloat
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> OffsetProbeView {
|
||||||
|
let view = OffsetProbeView()
|
||||||
|
view.onOffsetChanged = { value in
|
||||||
|
if offsetY != value {
|
||||||
|
offsetY = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: OffsetProbeView, context: Context) {
|
||||||
|
uiView.onOffsetChanged = { value in
|
||||||
|
if offsetY != value {
|
||||||
|
offsetY = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class OffsetProbeView: UIView {
|
||||||
|
var onOffsetChanged: ((CGFloat) -> Void)?
|
||||||
|
private var observation: NSKeyValueObservation?
|
||||||
|
private weak var observedScrollView: UIScrollView?
|
||||||
|
|
||||||
|
override func didMoveToWindow() {
|
||||||
|
super.didMoveToWindow()
|
||||||
|
attachIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func didMoveToSuperview() {
|
||||||
|
super.didMoveToSuperview()
|
||||||
|
attachIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attachIfNeeded() {
|
||||||
|
guard observation == nil else { return }
|
||||||
|
guard observedScrollView == nil else { return }
|
||||||
|
|
||||||
|
if let scrollView = enclosingScrollView() ?? findScrollViewInWindow() {
|
||||||
|
observe(scrollView)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAttach()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observe(_ scrollView: UIScrollView) {
|
||||||
|
observedScrollView = scrollView
|
||||||
|
observation = scrollView.observe(\.contentOffset, options: [.new, .initial]) { [weak self, weak scrollView] _, change in
|
||||||
|
guard let self, let scrollView, let y = change.newValue?.y else { return }
|
||||||
|
let adjusted = max(0, y + scrollView.adjustedContentInset.top)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onOffsetChanged?(adjusted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryAttach() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
||||||
|
self?.attachIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enclosingScrollView() -> UIScrollView? {
|
||||||
|
var current: UIView? = self
|
||||||
|
while let view = current {
|
||||||
|
if let scrollView = view as? UIScrollView {
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
current = view.superview
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findScrollViewInWindow() -> UIScrollView? {
|
||||||
|
guard let window else { return nil }
|
||||||
|
let targetPoint = convert(CGPoint(x: bounds.midX, y: bounds.midY), to: window)
|
||||||
|
return findScrollView(in: window, containing: targetPoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findScrollView(in root: UIView, containing point: CGPoint) -> UIScrollView? {
|
||||||
|
for subview in root.subviews.reversed() {
|
||||||
|
if let match = findScrollView(in: subview, containing: point) {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let scrollView = root as? UIScrollView {
|
||||||
|
let rectInWindow = scrollView.convert(scrollView.bounds, to: window)
|
||||||
|
if rectInWindow.contains(point) {
|
||||||
|
return scrollView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
observation?.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct AsyncStoreImage: View {
|
||||||
|
let imageURL: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let imageURL,
|
||||||
|
let url = URL(string: imageURL) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
default:
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(AppColors.brandSoft)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fallback: some View {
|
||||||
|
Image("placeholder-product")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProductDetailSheet: View {
|
||||||
|
let product: StoreCatalogProduct
|
||||||
|
let imageURL: String?
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
AsyncStoreImage(imageURL: imageURL)
|
||||||
|
.frame(height: 220)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||||
|
|
||||||
|
Text(product.name)
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
if let description = product.description, description.isEmpty == false {
|
||||||
|
Text(description)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(String(format: "R$ %.2f", product.price ?? 0).replacingOccurrences(of: ".", with: ","))
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
|
||||||
|
if product.addonGroups.isEmpty == false {
|
||||||
|
Text("Adicionais")
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
ForEach(product.addonGroups) { group in
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(group.name)
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
ForEach(group.items) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item.name)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
Spacer()
|
||||||
|
Text(String(format: "+ R$ %.2f", item.price ?? 0).replacingOccurrences(of: ".", with: ","))
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.padding(.bottom, 80)
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: {}) {
|
||||||
|
Image(systemName: "bag")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
PrimaryButton(title: "Comprar agora", action: {})
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
}
|
||||||
|
.background(AppColors.backgroundLight.ignoresSafeArea())
|
||||||
|
.navigationTitle("Detalhes")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Fechar") { dismiss() }
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user