This commit is contained in:
Daniel Arantes Loverde
2026-06-03 14:31:11 -03:00
parent 69dd67b8a5
commit 74848192a0
28 changed files with 134 additions and 617 deletions

View File

@@ -38,7 +38,6 @@
496EB72F2A6AE4DE00C1253A /* Skip.env */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Skip.env; path = ../Skip.env; sourceTree = "<group>"; };
496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PediFoods.xcconfig; sourceTree = "<group>"; };
496EB72F2A6AE4DE00C1253C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
499AB9082B0581F4005E8330 /* plugins */ = {isa = PBXFileReference; lastKnownFileType = folder; name = plugins; path = ../../Intermediates.noindex/BuildToolPluginIntermediates; sourceTree = BUILT_PRODUCTS_DIR; };
49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; };
49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
@@ -66,14 +65,6 @@
name = Products;
sourceTree = "<group>";
};
49AB54462B066A7E007B79B2 /* SkipStone */ = {
isa = PBXGroup;
children = (
499AB9082B0581F4005E8330 /* plugins */,
);
name = SkipStone;
sourceTree = "<group>";
};
49F90C1F2A52156200F06D93 = {
isa = PBXGroup;
children = (
@@ -83,7 +74,6 @@
496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */,
493609562A6B7EAE00C401E2 /* PediFoods */,
49F90C2A2A52156200F06D93 /* App */,
49AB54462B066A7E007B79B2 /* SkipStone */,
496BDBEC2B89A47800C09264 /* Products */,
);
sourceTree = "<group>";
@@ -110,7 +100,6 @@
499CD43A2AC5B799001AE8D8 /* Sources */,
499CD43C2AC5B799001AE8D8 /* Frameworks */,
499CD43E2AC5B799001AE8D8 /* Resources */,
499CD4452AC5B869001AE8D8 /* Run skip gradle */,
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */,
);
buildRules = (
@@ -173,26 +162,6 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
499CD4452AC5B869001AE8D8 /* Run skip gradle */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Run skip gradle";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = "/bin/sh -e";
shellScript = "if [ \"${SKIP_ZERO}\" != \"\" ]; then\n echo \"note: skipping skip due to SKIP_ZERO\"\n exit 0\nelif [ \"${ENABLE_PREVIEWS}\" = \"YES\" ]; then\n echo \"note: skipping skip due to ENABLE_PREVIEWS\"\n exit 0\nelif [ \"${ACTION}\" = \"install\" ]; then\n echo \"note: skipping skip due to archive install\"\n exit 0\nelif [ \"${PLATFORM_NAME}\" = \"iphonesimulator\" ]; then\n echo \"note: skipping skip gradle for iOS Simulator\"\n exit 0\nelif [ \"${SKIP_ACTION}\" = \"none\" ]; then\n echo \"note: skipping skip due to SKIP_ACTION none\"\n exit 0\nelse\n SKIP_ACTION=\"${SKIP_ACTION:-launch}\"\nfi\nPATH=${BUILD_ROOT}/Release:${BUILD_ROOT}/Debug:${BUILD_ROOT}/../../SourcePackages/artifacts/skip/skip/skip.artifactbundle/macos:${PATH}:${HOMEBREW_PREFIX:-/opt/homebrew}/bin\necho \"note: running gradle build with: $(which skip) gradle -p ${PWD}/../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\"\nskip gradle -p ../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */

View File

@@ -1,5 +1,4 @@
// swift-tools-version: 6.1
// This is a Skip (https://skip.tools) package.
import PackageDescription
let package = Package(
@@ -10,15 +9,12 @@ let package = Package(
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
],
dependencies: [
.package(url: "https://source.skip.tools/skip.git", from: "1.8.4"),
.package(path: "Vendor/skip-fuse-ui"),
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
],
targets: [
.target(name: "PediFoods", dependencies: [
.product(name: "SkipFuseUI", package: "skip-fuse-ui"),
.product(name: "LCEssentials", package: "lcessentials", condition: .when(platforms: [.iOS]))
], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
.product(name: "LCEssentials", package: "lcessentials")
], resources: [.process("Resources")]),
.testTarget(name: "PediFoodsTests", dependencies: ["PediFoods"]),
]
)

View File

@@ -39,6 +39,7 @@ struct PrimaryButtonLabel: View {
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.labelStyle(.titleOnly)
}
}

View File

@@ -1,54 +1,9 @@
import SwiftUI
struct SnackbarOverlay: View {
#if os(Android)
let center: SnackbarCenter
@State var current: SnackbarMessage?
@State var observer: Any?
#else
@ObservedObject var center: SnackbarCenter
#endif
var body: some View {
#if os(Android)
VStack {
if let message = current {
HStack(spacing: 10) {
if let icon = message.iconSystemName, !icon.isEmpty {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(message.title)
.font(AppTypography.heading3)
.multilineTextAlignment(.leading)
.lineLimit(3)
Spacer(minLength: 0)
}
.foregroundStyle(Color.white)
.padding(.top, 14)
.padding(.horizontal, 16)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor)
.appContentShape(Rectangle())
.onTapGesture {
center.handleTap()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(999)
}
Spacer()
}
.animation(.spring(response: 0.3, dampingFraction: 0.9), value: current?.id)
.allowsHitTesting(current != nil)
.onAppear {
current = center.current
attachObserverIfNeeded()
}
.onDisappear {
detachObserver()
}
#else
VStack {
if let message = center.current {
HStack(spacing: 10) {
@@ -79,27 +34,5 @@ struct SnackbarOverlay: View {
}
.animation(.spring(response: 0.3, dampingFraction: 0.9), value: center.current?.id)
.allowsHitTesting(center.current != nil)
#endif
}
#if os(Android)
private func attachObserverIfNeeded() {
guard observer == nil else { return }
observer = NotificationCenter.default.addObserver(
forName: .snackbarDidChange,
object: nil,
queue: nil
) { _ in
Task { @MainActor in
current = center.current
}
}
}
private func detachObserver() {
guard let observer else { return }
NotificationCenter.default.removeObserver(observer)
self.observer = nil
}
#endif
}

View File

@@ -95,21 +95,12 @@ struct FeaturedStoreCard: View {
private var storeIconPlaceholder: some View {
ZStack {
#if os(Android)
SwiftUI.Image("placeholder-product", bundle: .module)
.resizable()
.scaledToFill()
.opacity(0.7)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
#else
Image("placeholder-product")
.resizable()
.scaledToFill()
.opacity(0.7)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
#endif
Circle()
.fill(AppColors.surface.opacity(0.92))
@@ -129,6 +120,7 @@ struct FeaturedStoreCardModel: Identifiable {
let rating: Double
let reviews: String
let distance: String
let deliveryFee: Double?
let category: String
let promoText: String?
let isFavorite: Bool

View File

@@ -12,16 +12,10 @@ struct ContentView: View {
@State var shouldPulseLaunchSplash = true
@State var shouldPrepareAuthEntryAnimation = DefaultTokenStore().jwt == nil
@State var authEntryAnimationToken = 0
#if os(iOS)
@State private var sessionExpiredObserver: NSObjectProtocol?
#endif
@State var cartResetObserver: Any?
@State var appResumeObserver: Any?
#if os(Android)
@State var snackbarCenter = SnackbarCenter.shared
#else
@StateObject var snackbarCenter = SnackbarCenter.shared
#endif
var body: some View {
ZStack(alignment: .top) {
@@ -95,16 +89,12 @@ struct ContentView: View {
.onAppear {
attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
#if os(iOS)
attachSessionExpiredObserverIfNeeded()
#endif
}
.onDisappear {
detachCartResetObserver()
detachAppResumeObserver()
#if os(iOS)
detachSessionExpiredObserver()
#endif
}
}
@@ -402,7 +392,6 @@ struct ContentView: View {
}
}
#if os(iOS)
private func attachSessionExpiredObserverIfNeeded() {
guard sessionExpiredObserver == nil else { return }
sessionExpiredObserver = NotificationCenter.default.addObserver(
@@ -421,7 +410,6 @@ struct ContentView: View {
NotificationCenter.default.removeObserver(observer)
sessionExpiredObserver = nil
}
#endif
}
struct AddressPickerModalView: View {

View File

@@ -1,60 +1,30 @@
import Foundation
import SkipFuse
import OSLog
import SwiftUI
/// A logger for the PediFoods module.
let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods")
/// The shared top-level view for the app, loaded from the platform-specific App delegates below.
///
/// The default implementation merely loads the `ContentView` for the app and logs a message.
/* SKIP @bridge */public struct PediFoodsRootView : View {
/* SKIP @bridge */public init() {
}
public struct PediFoodsRootView: View {
public init() {}
public var body: some View {
ContentView()
.task {
logger.info("Skip app logs are viewable in the Xcode console for iOS; Android logs can be viewed in Studio or using adb logcat")
}
}
}
/// Global application delegate functions.
///
/// These functions can update a shared observable object to communicate app state changes to interested views.
/* SKIP @bridge */public final class PediFoodsAppDelegate : Sendable {
/* SKIP @bridge */public static let shared = PediFoodsAppDelegate()
public final class PediFoodsAppDelegate: Sendable {
public static let shared = PediFoodsAppDelegate()
private init() {
}
private init() {}
/* SKIP @bridge */public func onInit() {
logger.debug("onInit")
}
/* SKIP @bridge */public func onLaunch() {
logger.debug("onLaunch")
}
/* SKIP @bridge */public func onResume() {
public func onInit() { logger.debug("onInit") }
public func onLaunch() { logger.debug("onLaunch") }
public func onResume() {
logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)
}
/* SKIP @bridge */public func onPause() {
logger.debug("onPause")
}
/* SKIP @bridge */public func onStop() {
logger.debug("onStop")
}
/* SKIP @bridge */public func onDestroy() {
logger.debug("onDestroy")
}
/* SKIP @bridge */public func onLowMemory() {
logger.debug("onLowMemory")
}
public func onPause() { logger.debug("onPause") }
public func onStop() { logger.debug("onStop") }
public func onDestroy() { logger.debug("onDestroy") }
public func onLowMemory() { logger.debug("onLowMemory") }
}

View File

@@ -297,6 +297,9 @@
"Cardápio indisponível no momento." : {
"comment" : "A message displayed when a store's menu is unavailable.",
"isCommentAutoGenerated" : true
},
"Carregando cardápio..." : {
},
"Carregando pedido..." : {

View File

@@ -152,4 +152,38 @@ struct ValidateDeliveryAddressResult: Decodable {
let deliveryFee: Double?
let deliveryTime: String?
let sameCity: Bool?
enum CodingKeys: String, CodingKey {
case deliveryAllowed
case delivery_allowed
case reasonCode
case reason_code
case reasonMessage
case reason_message
case deliveryMode
case delivery_mode
case distance
case deliveryFee
case delivery_fee
case fee
case taxa
case deliveryTime
case delivery_time
case sameCity
case same_city
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
deliveryAllowed = (try? c.decode(Bool.self, forKey: .deliveryAllowed))
?? (try? c.decode(Bool.self, forKey: .delivery_allowed))
reasonCode = ApiService.decodeFlexibleString(from: c, keys: [.reasonCode, .reason_code])
reasonMessage = ApiService.decodeFlexibleString(from: c, keys: [.reasonMessage, .reason_message])
deliveryMode = ApiService.decodeFlexibleString(from: c, keys: [.deliveryMode, .delivery_mode])
distance = ApiService.decodeFlexibleDouble(from: c, keys: [.distance])
deliveryFee = ApiService.decodeFlexibleDouble(from: c, keys: [.deliveryFee, .delivery_fee, .fee, .taxa])
deliveryTime = ApiService.decodeFlexibleString(from: c, keys: [.deliveryTime, .delivery_time])
sameCity = (try? c.decode(Bool.self, forKey: .sameCity))
?? (try? c.decode(Bool.self, forKey: .same_city))
}
}

View File

@@ -8,8 +8,6 @@ enum ReviewPlatform: String, Encodable {
static var current: ReviewPlatform {
#if os(iOS)
return .ios
#elseif os(Android)
return .android
#else
return .web
#endif

View File

@@ -267,11 +267,7 @@ final class FeatureControlService {
}
private func platformName() -> String {
#if os(Android)
return "android"
#else
return "ios"
#endif
}
private func appVersion() -> String {

View File

@@ -1,12 +0,0 @@
# Configuration file for https://skip.tools project
#
# Kotlin dependencies and Gradle build options for this module can be configured here
#build:
# contents:
# - block: 'dependencies'
# contents:
# - 'implementation("androidx.compose.runtime:runtime")'
# this is a natively-compiled Skip Fuse module
skip:
mode: 'native'

View File

@@ -5,56 +5,6 @@ extension Notification.Name {
static let snackbarDidChange = Notification.Name("snackbarDidChange")
}
#if os(Android)
@MainActor
final class SnackbarCenter {
static let shared = SnackbarCenter()
var current: SnackbarMessage?
private var dismissTask: Task<Void, Never>?
func show(
title: String,
style: SnackbarStyle = .info,
icon: String? = nil,
duration: TimeInterval = 3.5,
action: (() -> Void)? = nil
) {
dismissTask?.cancel()
current = SnackbarMessage(
title: title,
style: style,
iconSystemName: icon,
duration: duration,
action: action
)
notifyChange()
dismissTask = Task { [weak self] in
let nanos = UInt64(max(0.2, duration) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanos)
guard !Task.isCancelled else { return }
self?.dismiss(animated: true)
}
}
func handleTap() {
let action = current?.action
dismiss(animated: true)
action?()
}
func dismiss(animated: Bool) {
dismissTask?.cancel()
dismissTask = nil
current = nil
notifyChange()
}
private func notifyChange() {
NotificationCenter.default.post(name: .snackbarDidChange, object: nil)
}
}
#else
@MainActor
final class SnackbarCenter: ObservableObject {
static let shared = SnackbarCenter()
@@ -105,7 +55,6 @@ final class SnackbarCenter: ObservableObject {
}
}
}
#endif
enum SnackbarStyle: Sendable {
case info

View File

@@ -17,9 +17,7 @@ struct TermsOfUseView: View {
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Termos de Uso")
#if os(iOS)
.appInlineNavigationTitle()
#endif
}
}
@@ -40,8 +38,6 @@ struct PrivacyPolicyView: View {
}
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
.navigationTitle("Privacidade")
#if os(iOS)
.appInlineNavigationTitle()
#endif
}
}

View File

@@ -14,13 +14,8 @@ struct LoginEmailView: View {
@Environment(\.colorScheme) var colorScheme
@ViewBuilder private var logoImage: some View {
#if os(Android)
SwiftUI.Image("pedifoods", bundle: .module)
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
var body: some View {
@@ -40,9 +35,9 @@ struct LoginEmailView: View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .numberPad, text: $phone)
.onChange(of: phone) { _, newValue in
let masked = formatPhoneBR(newValue)
if masked != newValue {
@@ -78,6 +73,7 @@ struct LoginEmailView: View {
dismiss()
}
}
.buttonStyle(.plain)
.font(AppTypography.body)
.padding(.top, 8)
@@ -125,6 +121,7 @@ struct LoginEmailView: View {
struct LoginField: View {
let icon: String
let placeholder: String
let keyboardType: UIKeyboardType
@Binding var text: String
var body: some View {
@@ -135,6 +132,7 @@ struct LoginField: View {
TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { }
.appNoAutoCap()
.foregroundColor(.black)
.keyboardType(keyboardType)
}
.padding(.horizontal, 16)
.frame(height: 52)

View File

@@ -15,23 +15,13 @@ struct LoginView: View {
@State var lastAnimatedToken = 0
@ViewBuilder private var logoImage: some View {
#if os(Android)
SwiftUI.Image("pedifoods", bundle: .module)
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
@ViewBuilder private var pinHeroImage: some View {
#if os(Android)
SwiftUI.Image(colorScheme == .dark ? "pin_image_app_dark" : "pin_image_app", bundle: .module)
.resizable()
#else
SwiftUI.Image("pin_image_app")
.resizable()
#endif
}
var body: some View {
@@ -43,13 +33,6 @@ struct LoginView: View {
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
.ignoresSafeArea()
#if os(Android)
pinHeroImage
.scaledToFill()
.frame(height: heroHeight + 80)
.offset(y: heroVisible ? -60 : -(heroHeight + 220))
.opacity(colorScheme == .dark ? 0.08 : 0.15)
#else
pinHeroImage
.scaledToFill()
.frame(height: heroHeight + 80)
@@ -61,7 +44,6 @@ struct LoginView: View {
endPoint: .bottom
)
)
#endif
VStack(spacing: 18) {
Spacer().frame(height: logoTopInset)
@@ -90,6 +72,7 @@ struct LoginView: View {
.tint(AppColors.tertiary)
.offset(y: buttonVisible ? 0 : 140)
.opacity(buttonVisible ? 1.0 : 0.0)
.buttonStyle(.plain)
HStack(spacing: 6) {
Text("Não tem conta ainda?")
@@ -97,6 +80,7 @@ struct LoginView: View {
Button("Criar conta") {
navigate(.registration)
}
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
}
.font(AppTypography.body)
@@ -108,36 +92,15 @@ struct LoginView: View {
}
.ignoresSafeArea()
.onAppear {
#if os(Android)
// No Android, rodamos a animação localmente de forma direta
applyHiddenStateWithoutAnimation()
Task {
try? await Task.sleep(nanoseconds: 100_000_000)
withAnimation(.easeOut(duration: 0.5)) {
heroVisible = true
}
try? await Task.sleep(nanoseconds: 150_000_000)
withAnimation(.easeOut(duration: 0.4)) {
textVisible = true
}
try? await Task.sleep(nanoseconds: 150_000_000)
withAnimation(.easeOut(duration: 0.4)) {
buttonVisible = true
}
}
#else
if shouldPrepareEntryAnimation {
applyHiddenStateWithoutAnimation()
} else {
showFinalStateWithoutAnimation()
}
#endif
}
#if os(iOS)
.task(id: authEntryAnimationToken) {
await runEntryAnimationIfNeeded(for: authEntryAnimationToken)
}
#endif
}
}

View File

@@ -25,19 +25,11 @@ struct OtpView: View {
var body: some View {
ScrollView {
VStack(spacing: 0) {
#if os(Android)
SwiftUI.Image("pedifoods", bundle: .module)
.resizable()
.scaledToFit()
.frame(width: 74, height: 74)
.padding(.top, 140)
#else
SwiftUI.Image("pedifoods")
.resizable()
.scaledToFit()
.frame(width: 74, height: 74)
.padding(.top, 140)
#endif
Text("Verificação")
.font(AppTypography.heading1)
@@ -72,6 +64,10 @@ struct OtpView: View {
if trimmed != newValue {
otp = trimmed
}
if trimmed.count == 8 && !isLoading {
isOtpFocused = false
validateOtp()
}
}
}
.frame(height: 204)
@@ -92,6 +88,7 @@ struct OtpView: View {
Button(resendButtonTitle) {
resendOtp()
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.disabled(isResending || !canResend)
@@ -101,6 +98,7 @@ struct OtpView: View {
Button("Colar código") {
pasteOtpFromClipboard()
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
.padding(.top, 22)
@@ -124,6 +122,7 @@ struct OtpView: View {
.padding(.bottom, 18)
}
}
.scrollDismissesKeyboard(.interactively)
.background(AppColors.backgroundLight)
.ignoresSafeArea()
.onAppear {

View File

@@ -18,13 +18,8 @@ struct RegistrationView: View {
private var isFormValid: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !phone.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && acceptedTerms }
@ViewBuilder private var logoImage: some View {
#if os(Android)
SwiftUI.Image("pedifoods", bundle: .module)
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
var body: some View {
@@ -49,9 +44,9 @@ struct RegistrationView: View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "person", placeholder: "Ex: Maria Silva", text: $name)
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
LoginField(icon: "person", placeholder: "Ex: Maria Silva", keyboardType: .default, text: $name)
LoginField(icon: "envelope", placeholder: "seu@email.com", keyboardType: .emailAddress, text: $email)
LoginField(icon: "phone", placeholder: "(00) 00000-0000", keyboardType: .namePhonePad, text: $phone)
.onChange(of: phone) { _, newValue in
let masked = formatPhoneBR(newValue)
if masked != newValue {

View File

@@ -36,12 +36,7 @@ struct LaunchSplashView: View {
}
@ViewBuilder var splashLogo: some View {
#if os(Android)
SwiftUI.Image("pedifoods", bundle: .module)
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
}

View File

@@ -32,13 +32,8 @@ struct AddAddressFormView: View {
}
@ViewBuilder private var logoImage: some View {
#if os(Android)
SwiftUI.Image("pedifoods", bundle: .module)
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
var body: some View {
@@ -70,8 +65,8 @@ struct AddAddressFormView: View {
.padding(.bottom, 20)
VStack(alignment: .leading, spacing: 16) {
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", text: $label)
LoginField(icon: "mail", placeholder: "CEP", text: $zipCode)
LoginField(icon: "tag", placeholder: "Ex: Casa, Trabalho", keyboardType: .default, text: $label)
LoginField(icon: "mail", placeholder: "CEP", keyboardType: .numberPad, text: $zipCode)
.onChange(of: zipCode) { _, newValue in
let masked = formatZipCodeBR(newValue)
if masked != newValue {
@@ -99,12 +94,12 @@ struct AddAddressFormView: View {
.foregroundStyle(AppColors.textMuted)
.padding(.horizontal, 6)
}
LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", text: $address)
LoginField(icon: "number", placeholder: "Número", text: $number)
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", text: $complement)
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", text: $neighborhood)
LoginField(icon: "building.2", placeholder: "Cidade", text: $city)
LoginField(icon: "map", placeholder: "Estado (UF)", text: $state)
LoginField(icon: "mappin.and.ellipse", placeholder: "Rua / Avenida", keyboardType: .default, text: $address)
LoginField(icon: "number", placeholder: "Número", keyboardType: .default, text: $number)
LoginField(icon: "plus.app", placeholder: "Complemento (opcional)", keyboardType: .default, text: $complement)
LoginField(icon: "square.grid.2x2", placeholder: "Bairro", keyboardType: .default, text: $neighborhood)
LoginField(icon: "building.2", placeholder: "Cidade", keyboardType: .default, text: $city)
LoginField(icon: "map", placeholder: "Estado (UF)", keyboardType: .default, text: $state)
.onChange(of: state) { _, newValue in
let normalized = String(newValue.uppercased().prefix(2))
if normalized != newValue {

View File

@@ -61,6 +61,7 @@ extension HomeView {
rating: store.rating ?? 0,
reviews: String(store.positiveReviews ?? store.reviewsCount ?? 0),
distance: formatDistance(store.distance),
deliveryFee: store.deliveryFee,
category: store.category ?? "Loja",
promoText: nil,
isFavorite: appState.favorites.storeIds.contains(store.id),

View File

@@ -101,6 +101,7 @@ struct HomeView: View {
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance,
storeDeliveryFee: store.deliveryFee,
appState: $appState
)
} label: {
@@ -158,6 +159,7 @@ struct HomeView: View {
)
}
}
.buttonStyle(.plain)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.primary)
}
@@ -179,6 +181,7 @@ struct HomeView: View {
storeCategory: store.category,
storeRating: store.rating,
storeDistance: store.distance,
storeDeliveryFee: store.deliveryFee,
appState: $appState
)
} label: {

View File

@@ -49,6 +49,7 @@ struct SearchBar: View {
Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.frame(height: 52)

View File

@@ -222,29 +222,15 @@ struct OrderTrackingView: View {
Group {
if hasTrackingImage {
#if os(Android)
SwiftUI.Image(trackingImageName, bundle: .module)
.renderingMode(.original)
.resizable()
.scaledToFit()
#else
Image(trackingImageName)
.renderingMode(.original)
.resizable()
.scaledToFit()
#endif
} else if hasPlaceholderProductImage {
#if os(Android)
SwiftUI.Image("placeholder-product", bundle: .module)
.renderingMode(.original)
.resizable()
.scaledToFit()
#else
Image("placeholder-product")
.renderingMode(.original)
.resizable()
.scaledToFit()
#endif
} else {
ZStack {
Color.black.opacity(0.08)

View File

@@ -688,58 +688,57 @@ struct OrderEntryDestinationView: View {
func resolveRoute() async {
defer { isResolvingRoute = false }
if routeIntent == .tracking {
return
}
let order = await fetchOrderForRouting()
guard let order else { return }
if routeIntent == .details {
orderDetails = order
return
}
if shouldOpenOrderDetails(for: order) {
// For both .tracking and .auto: show payment screen if payment is still pending.
// Timeline only shows once payment is confirmed or method is off-app.
if shouldOpenPaymentScreen(for: order) {
let normalizedMethod = normalizePaymentMethod(order)
if normalizedMethod.contains("PIX") {
let pixFromPayment = order.payment?.pix
let pixFromPayload = order.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste
: pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage
: pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
pixContext = PixPaymentContext(
id: order.id,
orderId: order.id,
shortId: order.shortId ?? initialShortId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate
)
return
}
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
cardContext = CardPaymentContext(
orderId: order.id,
shortId: order.shortId ?? initialShortId,
total: order.total ?? fallbackTotal ?? 0
)
return
}
}
// For .auto only: route terminal/canceled orders to details instead of timeline.
if routeIntent == .auto && shouldOpenOrderDetails(for: order) {
orderDetails = order
return
}
guard shouldOpenPaymentScreen(for: order) else { return }
let normalizedMethod = normalizePaymentMethod(order)
if normalizedMethod.contains("PIX") {
let pixFromPayment = order.payment?.pix
let pixFromPayload = order.paymentPayload
let copyPaste = (pixFromPayment?.copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.copyPaste
: pixFromPayload?.copyPaste
let qrCodeImage = (pixFromPayment?.qrCodeImage?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.qrCodeImage
: pixFromPayload?.qrCodeImage
let expirationDate = (pixFromPayment?.expirationDate?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? pixFromPayment?.expirationDate
: pixFromPayload?.expirationDate
pixContext = PixPaymentContext(
id: order.id,
orderId: order.id,
shortId: order.shortId ?? initialShortId,
copyPaste: copyPaste?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
? (copyPaste ?? "")
: "Código PIX indisponível no momento. Aguarde e tente novamente.",
qrCodeImageBase64: qrCodeImage,
expirationDate: expirationDate
)
return
}
if normalizedMethod == "CREDIT_CARD" || normalizedMethod == "DEBIT_CARD" {
cardContext = CardPaymentContext(
orderId: order.id,
shortId: order.shortId ?? initialShortId,
total: order.total ?? fallbackTotal ?? 0
)
return
}
// .tracking (and .auto fallthrough) nil states body renders OrderTrackingView
}
@MainActor

View File

@@ -81,6 +81,7 @@ struct PizzaFlavorAddonsSheet: View {
.appInlineNavigationTitle()
.appTopBarTrailingToolbar {
Button("Concluir") { dismiss() }
.buttonStyle(.plain)
.foregroundStyle(AppColors.primary)
}
}

View File

@@ -1,224 +0,0 @@
#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 {
if let imageSource = resolvedProfilePicture {
AsyncStoreImage(imageURL: imageSource)
.frame(width: 104, height: 104)
.clipShape(Circle())
} else {
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()
textFieldSection(
title: "Foto (URL/Base64)",
placeholder: "https://... ou data:image/jpeg;base64,...",
text: $profilePicture
)
.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 resolvedProfilePicture: String? {
let trimmed = profilePicture.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.isEmpty == false else { return nil }
return ImageSourceResolver.resolve(trimmed)
}
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,9 +1,6 @@
#if !os(Android)
import SwiftUI
#if canImport(PhotosUI) && os(iOS)
#if os(iOS)
import PhotosUI
#endif
#if canImport(UIKit)
import UIKit
#endif
@@ -17,8 +14,8 @@ struct UserProfileView: View {
@State var profilePicture: String = ""
@State var isSaving = false
#if canImport(PhotosUI) && os(iOS)
@State var selectedPhotoItem: PhotosPickerItem?
#if os(iOS)
@State private var selectedPhotoItem: PhotosPickerItem?
#endif
var body: some View {
@@ -38,7 +35,7 @@ struct UserProfileView: View {
.onAppear {
hydrateFromAppState()
}
#if canImport(PhotosUI) && os(iOS)
#if os(iOS)
.onChange(of: selectedPhotoItem) { _, newItem in
Task { await applySelectedPhoto(newItem) }
}
@@ -63,7 +60,7 @@ struct UserProfileView: View {
}
HStack(spacing: 10) {
#if canImport(PhotosUI) && os(iOS)
#if os(iOS)
PhotosPicker(selection: $selectedPhotoItem, matching: .images, photoLibrary: .shared()) {
Text("Trocar Foto")
.font(AppTypography.caption)
@@ -244,19 +241,15 @@ struct UserProfileView: View {
}
}
#if canImport(PhotosUI) && os(iOS)
#if os(iOS)
@MainActor
private func applySelectedPhoto(_ item: PhotosPickerItem?) async {
guard let item else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
#if canImport(UIKit)
guard let image = UIImage(data: data),
let jpegData = image.jpegData(compressionQuality: 0.82) else { return }
profilePicture = "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
#else
profilePicture = "data:image/jpeg;base64,\(data.base64EncodedString())"
#endif
} catch {
SnackbarCenter.shared.show(
title: "Não foi possível carregar a foto selecionada.",
@@ -268,4 +261,3 @@ struct UserProfileView: View {
}
#endif
}
#endif