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 24c8709..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 @@ -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 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/Support/PlatformCompat.swift b/pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift index 64c91f9..092b0b3 100644 --- a/pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift +++ b/pedi-foods/Sources/PediFoods/Support/PlatformCompat.swift @@ -13,8 +13,8 @@ import UIKit #if !canImport(UIKit) struct UIDevice { - static let topNotch: CGFloat = 0 - static let bottomNotch: CGFloat = 0 + static let topNotch = 0.0 + static let bottomNotch = 0.0 var modelName: String { "android" @@ -67,6 +67,37 @@ extension View { 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/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/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 1e949d3..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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift index d031fbd..a7ddc49 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/CheckoutView.swift @@ -120,7 +120,7 @@ struct CheckoutView: View { .background(AppColors.backgroundLight) .navigationTitle("Finalizar Pedido") .appInlineNavigationTitle() - .safeAreaInset(edge: .bottom) { + .appBottomSafeAreaInset { bottomBar } .task { @@ -433,7 +433,7 @@ struct CheckoutView: View { } .padding(.horizontal, 14) .padding(.top, 14) - .contentShape(Rectangle()) + .appContentShape(Rectangle()) .onTapGesture(perform: onTap) content() diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 880e2d9..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 @@ -451,18 +450,26 @@ 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 } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift index 7bbd472..10cd7b4 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderDetailsView.swift @@ -24,7 +24,7 @@ struct OrderDetailsView: View { .background(AppColors.backgroundLight) .navigationTitle("Detalhes do Pedido") .appInlineNavigationTitle() - .safeAreaInset(edge: .bottom) { + .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 f19edee..70006c6 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -22,6 +22,7 @@ struct OrderTrackingView: View { @State var showCancellationReason = false @State var reviewDraft: ReviewDraft? = nil @State var didSaveReviewForCurrentOrder = false + @State var reviewSavedObserver: Any? var body: some View { ScrollView(showsIndicators: false) { @@ -61,21 +62,41 @@ struct OrderTrackingView: View { } 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() @@ -754,23 +775,23 @@ 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 diff --git a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift index 9e3df0c..1325268 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/OrdersView.swift @@ -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) @@ -719,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/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 } }) { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProductDetailSheet.swift index dde14e6..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 } }) { diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 98b6007..495ddc8 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -91,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/StoreDetailView.swift b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift index 8731b11..4834975 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/StoreDetailView.swift @@ -50,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 @@ -57,6 +66,7 @@ struct StoreDetailView: View { sectionedProducts } +#endif } .refreshable { await loadStoreData(forceRefresh: true) @@ -472,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) @@ -484,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 3ce4028..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 @@ -267,3 +268,4 @@ struct UserProfileView: View { } #endif } +#endif diff --git a/skip_for_android_plan.md b/skip_for_android_plan.md index d59daf9..3d08135 100644 --- a/skip_for_android_plan.md +++ b/skip_for_android_plan.md @@ -288,8 +288,8 @@ Legenda: 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. +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. @@ -308,3 +308,13 @@ Legenda: 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.