feat(android): complete first successful skip export
This commit is contained in:
@@ -11,7 +11,9 @@ struct CachedRemoteImage<Placeholder: View>: 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<Placeholder: View>: 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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,25 +114,17 @@ struct LoginView: View {
|
||||
|
||||
@MainActor
|
||||
private func applyHiddenStateWithoutAnimation() {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
heroVisible = false
|
||||
textVisible = false
|
||||
buttonVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func showFinalStateWithoutAnimation() {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
heroVisible = true
|
||||
textVisible = true
|
||||
buttonVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runEntryAnimationIfNeeded(for token: Int) async {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -24,7 +24,7 @@ struct OrderDetailsView: View {
|
||||
.background(AppColors.backgroundLight)
|
||||
.navigationTitle("Detalhes do Pedido")
|
||||
.appInlineNavigationTitle()
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appBottomSafeAreaInset {
|
||||
VStack {
|
||||
reorderButton
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ extension PizzaProductDetailSheet {
|
||||
.font(AppTypography.heading3)
|
||||
.foregroundStyle(AppColors.textPrimary)
|
||||
|
||||
ForEach(Array(sizeItems.indices), id: \.self) { index in
|
||||
ForEach(0..<sizeItems.count, id: \.self) { index in
|
||||
sizeRow(sizeItems[index])
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ extension PizzaProductDetailSheet {
|
||||
.font(.caption)
|
||||
.foregroundStyle(AppColors.textMuted)
|
||||
|
||||
ForEach(Array(flavorItems.indices), id: \.self) { index in
|
||||
ForEach(0..<flavorItems.count, id: \.self) { index in
|
||||
let flavor = flavorItems[index]
|
||||
let isSelected = selectedFlavorIds.contains(flavor.id)
|
||||
let price = selectedSizeId.flatMap { flavor.pizzaPrices[$0] } ?? flavor.price
|
||||
@@ -118,7 +118,7 @@ extension PizzaProductDetailSheet {
|
||||
.disabled(disableSwitch)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard isSelected else { return }
|
||||
guard flavor.addonGroups.contains(where: { $0.items.isEmpty == false }) else { return }
|
||||
@@ -172,7 +172,7 @@ extension PizzaProductDetailSheet {
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.appContentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ struct PizzaProductDetailSheet: View {
|
||||
.padding(20)
|
||||
.padding(.bottom, 90)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appBottomSafeAreaInset {
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: { if quantity > 1 { quantity -= 1 } }) {
|
||||
|
||||
@@ -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 } }) {
|
||||
|
||||
@@ -91,7 +91,7 @@ struct ProfileView: View {
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
.appBottomSafeAreaInset {
|
||||
Rectangle()
|
||||
.fill(AppColors.backgroundLight.opacity(0.8))
|
||||
.frame(height: tabBarClearance)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user