feat(android): complete first successful skip export

This commit is contained in:
Daniel Arantes Loverde
2026-03-20 10:56:22 -03:00
parent cb7bfef356
commit 9bdb9dfdea
22 changed files with 360 additions and 153 deletions

View File

@@ -11,7 +11,9 @@ struct CachedRemoteImage<Placeholder: View>: View {
let ttl: TimeInterval let ttl: TimeInterval
let placeholder: Placeholder let placeholder: Placeholder
#if canImport(UIKit) || canImport(AppKit)
@StateObject var loader = CachedRemoteImageLoader() @StateObject var loader = CachedRemoteImageLoader()
#endif
init( init(
imageURL: String?, imageURL: String?,
@@ -60,14 +62,19 @@ struct CachedRemoteImage<Placeholder: View>: View {
#endif #endif
} }
.onAppear { .onAppear {
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: imageURL, ttl: ttl) loader.load(imageURL: imageURL, ttl: ttl)
#endif
} }
.onChange(of: imageURL) { _, newValue in .onChange(of: imageURL) { _, newValue in
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: newValue, ttl: ttl) loader.load(imageURL: newValue, ttl: ttl)
#endif
} }
} }
} }
#if canImport(UIKit) || canImport(AppKit)
@MainActor @MainActor
final class CachedRemoteImageLoader: ObservableObject { final class CachedRemoteImageLoader: ObservableObject {
#if canImport(UIKit) #if canImport(UIKit)
@@ -174,3 +181,4 @@ final class CachedRemoteImageLoader: ObservableObject {
} }
#endif #endif
} }
#endif

View File

@@ -30,7 +30,7 @@ struct SnackbarOverlay: View {
.padding(.bottom, 16) .padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor) .background(message.style.backgroundColor)
.contentShape(Rectangle()) .appContentShape(Rectangle())
.onTapGesture { .onTapGesture {
center.handleTap() center.handleTap()
} }

View File

@@ -14,6 +14,7 @@ struct ContentView: View {
#if os(iOS) #if os(iOS)
@State private var sessionExpiredObserver: NSObjectProtocol? @State private var sessionExpiredObserver: NSObjectProtocol?
#endif #endif
@State var cartResetObserver: Any?
#if os(Android) #if os(Android)
@State var snackbarCenter = SnackbarCenter.shared @State var snackbarCenter = SnackbarCenter.shared
#else #else
@@ -69,9 +70,6 @@ struct ContentView: View {
.onChange(of: appState.address.longitude) { _, _ in .onChange(of: appState.address.longitude) { _, _ in
dismissAddressPickerIfAddressExists() dismissAddressPickerIfAddressExists()
} }
.onReceive(NotificationCenter.default.publisher(for: .cartDidReset)) { _ in
appState.cart = CartState()
}
.task { .task {
await performInitialLaunchBootstrap() await performInitialLaunchBootstrap()
} }
@@ -83,11 +81,13 @@ struct ContentView: View {
} }
} }
.onAppear { .onAppear {
attachCartResetObserverIfNeeded()
#if os(iOS) #if os(iOS)
attachSessionExpiredObserverIfNeeded() attachSessionExpiredObserverIfNeeded()
#endif #endif
} }
.onDisappear { .onDisappear {
detachCartResetObserver()
#if os(iOS) #if os(iOS)
detachSessionExpiredObserver() detachSessionExpiredObserver()
#endif #endif
@@ -182,6 +182,23 @@ struct ContentView: View {
isBootstrappingSession = false 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 @MainActor
private func hydrateAppState(with customer: CustomerProfile) { private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id appState.profile.id = customer.id

View File

@@ -76,18 +76,18 @@ final class OrderRealtimeTracker {
@discardableResult @discardableResult
private func fetchLatest(orderId: String) async -> PublicOrderResult? { private func fetchLatest(orderId: String) async -> PublicOrderResult? {
do { 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) let response = try await ApiService().publicOrder(orderId: orderId)
guard response.error == false, let order = response.result else { 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 return nil
} }
clearPendingCartIfNeeded(for: order) 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) onOrderUpdated?(order)
return order return order
} catch { } 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 return nil
} }
} }

View File

@@ -13,8 +13,8 @@ import UIKit
#if !canImport(UIKit) #if !canImport(UIKit)
struct UIDevice { struct UIDevice {
static let topNotch: CGFloat = 0 static let topNotch = 0.0
static let bottomNotch: CGFloat = 0 static let bottomNotch = 0.0
var modelName: String { var modelName: String {
"android" "android"
@@ -67,6 +67,37 @@ extension View {
content() content()
} }
} }
#endif
}
@ViewBuilder
func appContentShape<S: Shape>(_ shape: S) -> some View {
#if os(Android)
self
#else
self.contentShape(shape)
#endif
}
@ViewBuilder
func appBottomSafeAreaInset<Content: View>(@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 #endif
} }
} }

View File

@@ -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)")
}
}
}

View File

@@ -114,25 +114,17 @@ struct LoginView: View {
@MainActor @MainActor
private func applyHiddenStateWithoutAnimation() { private func applyHiddenStateWithoutAnimation() {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
heroVisible = false heroVisible = false
textVisible = false textVisible = false
buttonVisible = false buttonVisible = false
} }
}
@MainActor @MainActor
private func showFinalStateWithoutAnimation() { private func showFinalStateWithoutAnimation() {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
heroVisible = true heroVisible = true
textVisible = true textVisible = true
buttonVisible = true buttonVisible = true
} }
}
@MainActor @MainActor
private func runEntryAnimationIfNeeded(for token: Int) async { private func runEntryAnimationIfNeeded(for token: Int) async {

View File

@@ -129,7 +129,6 @@ struct AddressCard: View {
.font(AppTypography.heading2) .font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.9) .minimumScaleFactor(0.9)
if item.isPrimary { if item.isPrimary {
@@ -137,9 +136,7 @@ struct AddressCard: View {
.font(AppTypography.overline) .font(AppTypography.overline)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
.lineLimit(1) .lineLimit(1)
.fixedSize(horizontal: true, vertical: false) .padding(EdgeInsets(top: 5, leading: 9, bottom: 5, trailing: 9))
.padding(.horizontal, 9)
.padding(.vertical, 5)
.background(AppColors.tertiary) .background(AppColors.tertiary)
.clipShape(Capsule()) .clipShape(Capsule())
} }

View File

@@ -79,7 +79,7 @@ struct AddressesView: View {
item: addressToListItem(address, isPrimary: isSelected), item: addressToListItem(address, isPrimary: isSelected),
onEdit: { beginEditing(address) } onEdit: { beginEditing(address) }
) )
.contentShape(Rectangle()) .appContentShape(Rectangle())
.onTapGesture { .onTapGesture {
if openSwipeRowId == rowId { if openSwipeRowId == rowId {
openSwipeRowId = nil openSwipeRowId = nil

View File

@@ -120,7 +120,7 @@ struct CheckoutView: View {
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Finalizar Pedido") .navigationTitle("Finalizar Pedido")
.appInlineNavigationTitle() .appInlineNavigationTitle()
.safeAreaInset(edge: .bottom) { .appBottomSafeAreaInset {
bottomBar bottomBar
} }
.task { .task {
@@ -433,7 +433,7 @@ struct CheckoutView: View {
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.top, 14) .padding(.top, 14)
.contentShape(Rectangle()) .appContentShape(Rectangle())
.onTapGesture(perform: onTap) .onTapGesture(perform: onTap)
content() content()

View File

@@ -1,4 +1,3 @@
import Foundation
import SwiftUI import SwiftUI
#if os(iOS) #if os(iOS)
import LCEssentials import LCEssentials
@@ -451,18 +450,26 @@ struct HomeView: View {
let display = appState.address.display let display = appState.address.display
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() .lowercased()
let lat = appState.address.latitude.map { String(format: "%.5f", $0) } ?? "nil" let lat = appState.address.latitude.map(formatCoordinateScope) ?? "nil"
let lng = appState.address.longitude.map { String(format: "%.5f", $0) } ?? "nil" let lng = appState.address.longitude.map(formatCoordinateScope) ?? "nil"
return "\(selected)|\(display)|\(lat)|\(lng)" return "\(selected)|\(display)|\(lat)|\(lng)"
} }
private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String { private func homeStoresCacheKey(lat: Double?, lng: Double?, category: String?) -> String {
let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all" let normalizedCategory = category?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "all"
let latKey = lat.map { String(format: "%.4f", $0) } ?? "nil" let latKey = lat.map(formatCoordinateCache) ?? "nil"
let lngKey = lng.map { String(format: "%.4f", $0) } ?? "nil" let lngKey = lng.map(formatCoordinateCache) ?? "nil"
return "stores:\(latKey):\(lngKey):\(normalizedCategory)" 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? { private var selectedCategoryQueryValue: String? {
guard selectedCategory.lowercased() != "all" else { return nil } guard selectedCategory.lowercased() != "all" else { return nil }
guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil } guard let selected = categories.first(where: { $0.id == selectedCategory }) else { return nil }

View File

@@ -24,7 +24,7 @@ struct OrderDetailsView: View {
.background(AppColors.backgroundLight) .background(AppColors.backgroundLight)
.navigationTitle("Detalhes do Pedido") .navigationTitle("Detalhes do Pedido")
.appInlineNavigationTitle() .appInlineNavigationTitle()
.safeAreaInset(edge: .bottom) { .appBottomSafeAreaInset {
VStack { VStack {
reorderButton reorderButton
.padding(.horizontal, 20) .padding(.horizontal, 20)

View File

@@ -22,6 +22,7 @@ struct OrderTrackingView: View {
@State var showCancellationReason = false @State var showCancellationReason = false
@State var reviewDraft: ReviewDraft? = nil @State var reviewDraft: ReviewDraft? = nil
@State var didSaveReviewForCurrentOrder = false @State var didSaveReviewForCurrentOrder = false
@State var reviewSavedObserver: Any?
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
@@ -61,21 +62,41 @@ struct OrderTrackingView: View {
} }
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt) tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
} }
.onAppear {
attachReviewSavedObserverIfNeeded()
}
.onDisappear { .onDisappear {
tracker.stop() tracker.stop()
detachReviewSavedObserver()
} }
.navigationDestination(item: $reviewDraft) { draft in .navigationDestination(item: $reviewDraft) { draft in
MyReviewsView(initialOrder: draft) 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 } 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 { if orderIdFromEvent.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == currentOrderId {
didSaveReviewForCurrentOrder = true didSaveReviewForCurrentOrder = true
} }
} }
} }
private func detachReviewSavedObserver() {
guard let reviewSavedObserver else { return }
NotificationCenter.default.removeObserver(reviewSavedObserver)
self.reviewSavedObserver = nil
}
private var topHeader: some View { private var topHeader: some View {
HStack(spacing: 10) { HStack(spacing: 10) {
Circle() Circle()
@@ -754,23 +775,23 @@ struct OrderTrackingView: View {
@MainActor @MainActor
private func loadInitialOrder() async { private func loadInitialOrder() async {
logger.info("OrderTracking initial fetch orderId=\(orderId, privacy: .public)") logger.info("OrderTracking initial fetch orderId=\(orderId)")
do { do {
let response = try await ApiService().publicOrder(orderId: orderId) let response = try await ApiService().publicOrder(orderId: orderId)
if response.error { if response.error {
errorMessage = response.message ?? "Não foi possível carregar o pedido." 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 { } else if let result = response.result {
order = result order = result
storeContactPhone = result.storePhone storeContactPhone = result.storePhone
errorMessage = nil 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) await refreshStoreContactPhone(for: result)
} }
} catch { } catch {
errorMessage = "Não foi possível carregar o pedido." 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 isLoading = false

View File

@@ -137,7 +137,7 @@ struct OrdersView: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
.buttonStyle(.plain) .buttonStyle(.plain)
.layoutPriority(0) .appLayoutPriority(0)
Spacer(minLength: 8) Spacer(minLength: 8)
@@ -169,7 +169,7 @@ struct OrdersView: View {
.clipShape(Capsule()) .clipShape(Capsule())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.layoutPriority(2) .appLayoutPriority(2)
} }
} }
.padding(18) .padding(18)
@@ -719,16 +719,16 @@ struct OrderEntryDestinationView: View {
@MainActor @MainActor
func fetchOrderForRouting() async -> PublicOrderResult? { func fetchOrderForRouting() async -> PublicOrderResult? {
logger.info("OrderEntry fetch route orderId=\(orderId, privacy: .public)") logger.info("OrderEntry fetch route orderId=\(orderId)")
do { do {
let response = try await ApiService().publicOrder(orderId: orderId) let response = try await ApiService().publicOrder(orderId: orderId)
if response.error == false, let result = response.result { 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 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 { } 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 return nil
} }

View File

@@ -9,7 +9,7 @@ extension PizzaProductDetailSheet {
.font(AppTypography.heading3) .font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary) .foregroundStyle(AppColors.textPrimary)
ForEach(Array(sizeItems.indices), id: \.self) { index in ForEach(0..<sizeItems.count, id: \.self) { index in
sizeRow(sizeItems[index]) sizeRow(sizeItems[index])
} }
} }
@@ -83,7 +83,7 @@ extension PizzaProductDetailSheet {
.font(.caption) .font(.caption)
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
ForEach(Array(flavorItems.indices), id: \.self) { index in ForEach(0..<flavorItems.count, id: \.self) { index in
let flavor = flavorItems[index] let flavor = flavorItems[index]
let isSelected = selectedFlavorIds.contains(flavor.id) let isSelected = selectedFlavorIds.contains(flavor.id)
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
@@ -118,7 +118,7 @@ extension PizzaProductDetailSheet {
.disabled(disableSwitch) .disabled(disableSwitch)
} }
.padding(.vertical, 2) .padding(.vertical, 2)
.contentShape(Rectangle()) .appContentShape(Rectangle())
.onTapGesture { .onTapGesture {
guard isSelected else { return } guard isSelected else { return }
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return } guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
@@ -172,7 +172,7 @@ extension PizzaProductDetailSheet {
Spacer() Spacer()
} }
.contentShape(Rectangle()) .appContentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }

View File

@@ -222,7 +222,7 @@ struct PizzaProductDetailSheet: View {
.padding(20) .padding(20)
.padding(.bottom, 90) .padding(.bottom, 90)
} }
.safeAreaInset(edge: .bottom) { .appBottomSafeAreaInset {
HStack(spacing: 12) { HStack(spacing: 12) {
HStack(spacing: 10) { HStack(spacing: 10) {
Button(action: { if quantity > 1 { quantity -= 1 } }) { Button(action: { if quantity > 1 { quantity -= 1 } }) {

View File

@@ -164,7 +164,7 @@ struct ProductDetailSheet: View {
.padding(20) .padding(20)
.padding(.bottom, 80) .padding(.bottom, 80)
} }
.safeAreaInset(edge: .bottom) { .appBottomSafeAreaInset {
HStack(spacing: 12) { HStack(spacing: 12) {
HStack(spacing: 10) { HStack(spacing: 10) {
Button(action: { if quantity > 0 { quantity -= 1 } }) { Button(action: { if quantity > 0 { quantity -= 1 } }) {

View File

@@ -91,7 +91,7 @@ struct ProfileView: View {
} }
} }
.ignoresSafeArea(edges: .top) .ignoresSafeArea(edges: .top)
.safeAreaInset(edge: .bottom) { .appBottomSafeAreaInset {
Rectangle() Rectangle()
.fill(AppColors.backgroundLight.opacity(0.8)) .fill(AppColors.backgroundLight.opacity(0.8))
.frame(height: tabBarClearance) .frame(height: tabBarClearance)

View File

@@ -50,6 +50,15 @@ struct StoreDetailView: View {
AppColors.backgroundLight.ignoresSafeArea() AppColors.backgroundLight.ignoresSafeArea()
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
#if os(Android)
LazyVStack(spacing: 0) {
topSection
categoryTabs(proxy: proxy, safeTop: safeTop, isSticky: false)
sectionedProducts
}
#else
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
topSection topSection
@@ -57,6 +66,7 @@ struct StoreDetailView: View {
sectionedProducts sectionedProducts
} }
#endif
} }
.refreshable { .refreshable {
await loadStoreData(forceRefresh: true) await loadStoreData(forceRefresh: true)
@@ -472,7 +482,7 @@ struct StoreDetailView: View {
} }
.offset(x: 3, y: -3) .offset(x: 3, y: -3)
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
.contentShape(Circle()) .appContentShape(Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
@@ -484,7 +494,7 @@ struct StoreDetailView: View {
.padding(12) .padding(12)
.background(AppColors.surface) .background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.contentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) .appContentShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.onTapGesture { .onTapGesture {
guard isStoreOpen else { guard isStoreOpen else {
SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0) SnackbarCenter.shared.show(title: "Loja fechada no momento.", style: .warning, icon: "moon.zzz.fill", duration: 2.0)

View File

@@ -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<String>) -> 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

View File

@@ -1,3 +1,4 @@
#if !os(Android)
import SwiftUI import SwiftUI
#if canImport(PhotosUI) && os(iOS) #if canImport(PhotosUI) && os(iOS)
import PhotosUI import PhotosUI
@@ -267,3 +268,4 @@ struct UserProfileView: View {
} }
#endif #endif
} }
#endif

View File

@@ -288,8 +288,8 @@ Legenda:
1. 🟢 Revisar e blindar imports/dependências iOS-only no código compartilhado. 1. 🟢 Revisar e blindar imports/dependências iOS-only no código compartilhado.
2. 🟢 Corrigir o problema de artefato/caminho do ambiente Skip local. 2. 🟢 Corrigir o problema de artefato/caminho do ambiente Skip local.
3. 🟢 Validar `swift build` sem erro de ambiente. 3. 🟢 Validar `swift build` sem erro de ambiente.
4. 🔴 Validar export/build Android via Skip. 4. 🟢 Validar export/build Android via Skip.
5. 🔴 Gerar APK/build Android instalável. 5. 🟢 Gerar APK/build Android instalável.
6. 🔴 Instalar e abrir o app no Android. 6. 🔴 Instalar e abrir o app no Android.
7. 🔴 Validar login no Android. 7. 🔴 Validar login no Android.
8. 🔴 Validar home e navegação principal 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. 2. Task 2 concluída com ajuste local no cache/estado de build do Skip.
- Correção local não versionada. - Correção local não versionada.
3. Task 3 concluída com `swift build` finalizado com sucesso. 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.