updates
This commit is contained in:
@@ -1,76 +1,35 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
enum HomeScrollCoordinateSpace {
|
||||
static let name = "home-scroll"
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@MainActor
|
||||
struct ScrollOffsetObserver: UIViewRepresentable {
|
||||
let onOffsetChange: (CGFloat) -> Void
|
||||
struct HomeScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
func makeUIView(context: Context) -> ScrollOffsetProbeView {
|
||||
let view = ScrollOffsetProbeView()
|
||||
view.onOffsetChange = onOffsetChange
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ScrollOffsetProbeView, context: Context) {
|
||||
uiView.onOffsetChange = onOffsetChange
|
||||
uiView.attachIfNeeded()
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ScrollOffsetProbeView: UIView {
|
||||
var onOffsetChange: (CGFloat) -> Void = { _ in }
|
||||
struct ScrollOffsetObserver: View {
|
||||
let onOffsetChange: (CGFloat) -> Void
|
||||
|
||||
private weak var observedScrollView: UIScrollView?
|
||||
private var observation: NSKeyValueObservation?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
}
|
||||
|
||||
deinit {
|
||||
observation?.invalidate()
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
attachIfNeeded()
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
attachIfNeeded()
|
||||
}
|
||||
|
||||
func attachIfNeeded() {
|
||||
guard let scrollView = findEnclosingScrollView() else { return }
|
||||
guard scrollView !== observedScrollView else { return }
|
||||
|
||||
observation?.invalidate()
|
||||
observedScrollView = scrollView
|
||||
observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] sv, _ in
|
||||
self?.onOffsetChange(sv.contentOffset.y)
|
||||
}
|
||||
}
|
||||
|
||||
private func findEnclosingScrollView() -> UIScrollView? {
|
||||
var view: UIView? = self
|
||||
while let current = view {
|
||||
if let scrollView = current as? UIScrollView {
|
||||
return scrollView
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: HomeScrollOffsetPreferenceKey.self,
|
||||
value: geometry.frame(in: .named(HomeScrollCoordinateSpace.name)).minY
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(HomeScrollOffsetPreferenceKey.self) { minY in
|
||||
onOffsetChange(-minY)
|
||||
}
|
||||
view = current.superview
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
extension HomeView {
|
||||
@MainActor
|
||||
func toggleFavoriteStore(storeId: String, storeName: String) async {
|
||||
guard favoriteRequestStoreIds.contains(storeId) == false else { return }
|
||||
guard appState.session.isAuthenticated else {
|
||||
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||
return
|
||||
}
|
||||
|
||||
let isFavorite = appState.favorites.storeIds.contains(storeId)
|
||||
favoriteRequestStoreIds.insert(storeId)
|
||||
defer { favoriteRequestStoreIds.remove(storeId) }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||
guard response.error == false, let result = response.result else {
|
||||
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
return
|
||||
}
|
||||
|
||||
appState.favorites.storeIds = Set(result.favorites)
|
||||
let successTitle = isFavorite
|
||||
? "\(storeName) removida dos favoritos."
|
||||
: "\(storeName) adicionada aos favoritos."
|
||||
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||
} catch {
|
||||
let message: String
|
||||
if let networkError = error as? NetworkError {
|
||||
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else if let serviceError = error as? ApiServiceError {
|
||||
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else {
|
||||
message = "Não foi possível atualizar seus favoritos."
|
||||
}
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ struct HomeView: View {
|
||||
@State var stores: [StoreSummary] = []
|
||||
@State var productSearchIndexByStoreId: [String: [String]] = [:]
|
||||
@State var searchDebounceToken = 0
|
||||
@State var favoriteRequestStoreIds: Set<String> = []
|
||||
|
||||
private let specials: [SpecialOfferCardModel] = [
|
||||
// .init(id: "ddddd", title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
||||
@@ -47,7 +48,7 @@ struct HomeView: View {
|
||||
refreshCategories: true
|
||||
)
|
||||
}
|
||||
.background(scrollOffsetObserver)
|
||||
.coordinateSpace(name: HomeScrollCoordinateSpace.name)
|
||||
|
||||
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
@@ -84,6 +85,7 @@ struct HomeView: View {
|
||||
|
||||
private var contentStack: some View {
|
||||
VStack(spacing: 24) {
|
||||
scrollOffsetObserver
|
||||
categoriesSection
|
||||
|
||||
section(title: "Featured") {
|
||||
@@ -102,7 +104,14 @@ struct HomeView: View {
|
||||
appState: $appState
|
||||
)
|
||||
} label: {
|
||||
FeaturedStoreCard(store: store)
|
||||
FeaturedStoreCard(
|
||||
store: store,
|
||||
onFavoriteToggle: {
|
||||
Task {
|
||||
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(width: 190)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -171,7 +180,14 @@ struct HomeView: View {
|
||||
appState: $appState
|
||||
)
|
||||
} label: {
|
||||
FeaturedStoreCard(store: store)
|
||||
FeaturedStoreCard(
|
||||
store: store,
|
||||
onFavoriteToggle: {
|
||||
Task {
|
||||
await toggleFavoriteStore(storeId: store.id, storeName: store.name)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: [String: CGFloat] = [:]
|
||||
@@ -11,125 +8,41 @@ struct CategoryHeaderOffsetPreferenceKey: PreferenceKey {
|
||||
}
|
||||
}
|
||||
|
||||
enum StoreDetailScrollCoordinateSpace {
|
||||
static let name = "store-detail-scroll"
|
||||
}
|
||||
|
||||
struct ScrollOffsetPreferenceKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct ScrollOffsetReader: View {
|
||||
@Binding var offsetY: CGFloat
|
||||
|
||||
var body: some View {
|
||||
#if canImport(UIKit)
|
||||
ScrollOffsetReaderRepresentable(offsetY: $offsetY)
|
||||
#else
|
||||
Color.clear
|
||||
#endif
|
||||
.frame(height: 0)
|
||||
.background(
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: ScrollOffsetPreferenceKey.self,
|
||||
value: geometry.frame(in: .named(StoreDetailScrollCoordinateSpace.name)).minY
|
||||
)
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { minY in
|
||||
let normalizedOffset = max(0, -minY)
|
||||
if abs(offsetY - normalizedOffset) > 0.5 {
|
||||
offsetY = normalizedOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#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?
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
extension StoreDetailView {
|
||||
func heroIconButton(icon: String, action: @escaping () -> Void) -> some View {
|
||||
func heroIconButton(
|
||||
icon: String,
|
||||
foregroundStyle: Color = .white,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
.foregroundStyle(foregroundStyle)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Color.white.opacity(0.24))
|
||||
.clipShape(Circle())
|
||||
@@ -65,6 +69,49 @@ extension StoreDetailView {
|
||||
info?.isOpen ?? true
|
||||
}
|
||||
|
||||
var isFavoriteStore: Bool {
|
||||
appState.favorites.storeIds.contains(storeId)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func toggleFavoriteStore() async {
|
||||
guard isFavoriteRequestInFlight == false else { return }
|
||||
guard appState.session.isAuthenticated else {
|
||||
SnackbarCenter.shared.show(title: "Faça login para favoritar lojas.", style: .warning, icon: "person.crop.circle.badge.exclamationmark", duration: 2.5)
|
||||
return
|
||||
}
|
||||
|
||||
let isFavorite = isFavoriteStore
|
||||
isFavoriteRequestInFlight = true
|
||||
defer { isFavoriteRequestInFlight = false }
|
||||
|
||||
do {
|
||||
let response = try await ApiService().setStoreFavorite(storeId: storeId, isFavorite: isFavorite == false)
|
||||
guard response.error == false, let result = response.result else {
|
||||
let message = response.message ?? "Não foi possível atualizar seus favoritos."
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
return
|
||||
}
|
||||
|
||||
appState.favorites.storeIds = Set(result.favorites)
|
||||
let successTitle = isFavorite
|
||||
? "\(storeName) removida dos favoritos."
|
||||
: "\(storeName) adicionada aos favoritos."
|
||||
let successIcon = isFavorite ? "heart.slash.fill" : "heart.fill"
|
||||
SnackbarCenter.shared.show(title: successTitle, style: .success, icon: successIcon, duration: 2.2)
|
||||
} catch {
|
||||
let message: String
|
||||
if let networkError = error as? NetworkError {
|
||||
message = networkError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else if let serviceError = error as? ApiServiceError {
|
||||
message = serviceError.errorDescription ?? "Não foi possível atualizar seus favoritos."
|
||||
} else {
|
||||
message = "Não foi possível atualizar seus favoritos."
|
||||
}
|
||||
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 3.5)
|
||||
}
|
||||
}
|
||||
|
||||
var summaryCardHeight: CGFloat {
|
||||
summaryCardBaseHeight + (isStoreOpen ? 0 : closedBannerHeight)
|
||||
}
|
||||
@@ -97,9 +144,13 @@ extension StoreDetailView {
|
||||
if forceRefresh == false,
|
||||
let cachedInfo: StoreInfoResult = AppContentCache.shared.value(for: infoCacheKey, as: StoreInfoResult.self),
|
||||
let cachedCatalog: [StoreCatalogCategory] = AppContentCache.shared.value(for: catalogCacheKey, as: [StoreCatalogCategory].self) {
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(categories: cachedCatalog, storeId: storeId)
|
||||
info = cachedInfo
|
||||
categories = cachedCatalog
|
||||
selectedCategoryId = cachedCatalog.first?.id
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
@@ -110,9 +161,9 @@ extension StoreDetailView {
|
||||
}
|
||||
|
||||
do {
|
||||
async let infoRequest = ApiService().storeInfo(storeId: storeId)
|
||||
async let catalogRequest = ApiService().storeCatalog(storeId: storeId)
|
||||
let (infoResponse, catalogResponse) = try await (infoRequest, catalogRequest)
|
||||
let apiService = ApiService()
|
||||
let infoResponse = try await apiService.storeInfo(storeId: storeId)
|
||||
let catalogResponse = try await apiService.storeCatalog(storeId: storeId)
|
||||
|
||||
if infoResponse.error {
|
||||
errorMessage = infoResponse.message ?? "Não foi possível carregar a loja."
|
||||
@@ -125,13 +176,21 @@ extension StoreDetailView {
|
||||
return
|
||||
}
|
||||
|
||||
let normalizedCatalog = StoreCatalogNormalizer.sanitize(
|
||||
categories: catalogResponse.result ?? [],
|
||||
storeId: storeId
|
||||
)
|
||||
|
||||
info = infoResponse.result
|
||||
categories = catalogResponse.result ?? []
|
||||
selectedCategoryId = categories.first?.id
|
||||
categories = normalizedCatalog
|
||||
selectedCategoryId = StoreCatalogNormalizer.preferredCategoryId(
|
||||
from: normalizedCatalog,
|
||||
preferredId: selectedCategoryId
|
||||
)
|
||||
if let info = infoResponse.result {
|
||||
AppContentCache.shared.set(info, for: infoCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
}
|
||||
AppContentCache.shared.set(categories, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
AppContentCache.shared.set(normalizedCatalog, for: catalogCacheKey, ttl: AppCacheTTL.twoHours)
|
||||
isLoading = false
|
||||
} catch {
|
||||
isLoading = false
|
||||
|
||||
@@ -34,6 +34,7 @@ struct StoreDetailView: View {
|
||||
@State var categoryHeaderOffsets: [String: CGFloat] = [:]
|
||||
@State var isProgrammaticCategoryScroll = false
|
||||
@State var scrollOffsetY: CGFloat = 0
|
||||
@State var isFavoriteRequestInFlight = false
|
||||
|
||||
let cardTopInset: CGFloat = 168
|
||||
let summaryCardBaseHeight: CGFloat = 170
|
||||
@@ -52,6 +53,7 @@ struct StoreDetailView: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
#if os(Android)
|
||||
LazyVStack(spacing: 0) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||
topSection
|
||||
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||
@@ -60,6 +62,7 @@ struct StoreDetailView: View {
|
||||
}
|
||||
#else
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ScrollOffsetReader(offsetY: $scrollOffsetY)
|
||||
topSection
|
||||
|
||||
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
|
||||
@@ -71,8 +74,8 @@ struct StoreDetailView: View {
|
||||
.refreshable {
|
||||
await loadStoreData(forceRefresh: true)
|
||||
}
|
||||
.coordinateSpace(name: StoreDetailScrollCoordinateSpace.name)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.background(ScrollOffsetReader(offsetY: $scrollOffsetY))
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.overlay(alignment: .top) {
|
||||
@@ -218,7 +221,14 @@ struct StoreDetailView: View {
|
||||
}
|
||||
Spacer()
|
||||
heroIconButton(icon: "magnifyingglass") {}
|
||||
heroIconButton(icon: "heart") {}
|
||||
heroIconButton(
|
||||
icon: isFavoriteStore ? "heart.fill" : "heart",
|
||||
foregroundStyle: isFavoriteStore ? Color.red : Color.white
|
||||
) {
|
||||
Task {
|
||||
await toggleFavoriteStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, UIDevice.topNotch)
|
||||
|
||||
Reference in New Issue
Block a user