Not has address
This commit is contained in:
@@ -65,15 +65,18 @@ Used when interacting with a specific store (Catalog, Checkout).
|
|||||||
### 2. Login (Get Token)
|
### 2. Login (Get Token)
|
||||||
**POST** `/api/customer/login`
|
**POST** `/api/customer/login`
|
||||||
|
|
||||||
**Body:**
|
**Body (email + phoneNumber are mandatory):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"email": "daniel@example.com",
|
"email": "daniel@example.com",
|
||||||
"otp": "123456" // Em dev/demo, apenas email pode bastar se configurado assim
|
"phoneNumber": "+5511999999999"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Fluxo:**
|
||||||
|
- Passo 1: enviar `email` + `phoneNumber` para receber código por email.<OTP>`.
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ open class MainActivity: AppCompatActivity {
|
|||||||
|
|
||||||
ActivityCompat.requestPermissions(
|
ActivityCompat.requestPermissions(
|
||||||
this,
|
this,
|
||||||
arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION),
|
kotlin.arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION),
|
||||||
locationRequestCode
|
locationRequestCode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
||||||
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
||||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
|
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
|
||||||
|
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */,
|
||||||
491F27822DA55B72004926EE /* PediFoods in Frameworks */,
|
491F27822DA55B72004926EE /* PediFoods in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -115,6 +117,7 @@
|
|||||||
name = "PediFoods App";
|
name = "PediFoods App";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
491F27812DA55B72004926EE /* PediFoods */,
|
491F27812DA55B72004926EE /* PediFoods */,
|
||||||
|
B13CABF12F36437300469FD6 /* LCEssentials */,
|
||||||
);
|
);
|
||||||
productName = App;
|
productName = App;
|
||||||
productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */;
|
productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */;
|
||||||
@@ -143,6 +146,7 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 49F90C1F2A52156200F06D93;
|
mainGroup = 49F90C1F2A52156200F06D93;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 496BDBEC2B89A47800C09264 /* Products */;
|
productRefGroup = 496BDBEC2B89A47800C09264 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -282,11 +286,27 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 1.0.5;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
491F27812DA55B72004926EE /* PediFoods */ = {
|
491F27812DA55B72004926EE /* PediFoods */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = PediFoods;
|
productName = PediFoods;
|
||||||
};
|
};
|
||||||
|
B13CABF12F36437300469FD6 /* LCEssentials */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */;
|
||||||
|
productName = LCEssentials;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 49F90C202A52156200F06D93 /* Project object */;
|
rootObject = 49F90C202A52156200F06D93 /* Project object */;
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://source.skip.tools/skip.git", from: "1.7.0"),
|
.package(url: "https://source.skip.tools/skip.git", from: "1.7.0"),
|
||||||
.package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0")
|
.package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"),
|
||||||
|
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "PediFoods", dependencies: [
|
.target(name: "PediFoods", dependencies: [
|
||||||
.product(name: "SkipFuseUI", package: "skip-fuse-ui")
|
.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")]),
|
], resources: [.process("Resources")], plugins: [.plugin(name: "skipstone", package: "skip")]),
|
||||||
|
.testTarget(name: "PediFoodsTests", dependencies: ["PediFoods"]),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,3 +21,6 @@ ANDROID_PACKAGE_NAME = pedi.foods
|
|||||||
|
|
||||||
// If your Android appId is different from the iOS Bundle Identifer, specify it here
|
// If your Android appId is different from the iOS Bundle Identifer, specify it here
|
||||||
// ANDROID_APPLICATION_ID = com.br.pedifoods.app
|
// ANDROID_APPLICATION_ID = com.br.pedifoods.app
|
||||||
|
|
||||||
|
// Atomenta endpoint
|
||||||
|
ATOMENTA_API_URL = "https://atomenta.com.br"
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ struct SearchField: View {
|
|||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.foregroundStyle(AppColors.secondary)
|
.foregroundStyle(AppColors.secondary)
|
||||||
TextField(placeholder, text: $text)
|
TextField(placeholder, text: $text)
|
||||||
.textInputAutocapitalization(.never)
|
.appNoAutoCap()
|
||||||
.autocorrectionDisabled()
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.frame(height: 52)
|
.frame(height: 52)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SnackbarOverlay: View {
|
||||||
|
#if os(Android)
|
||||||
|
let center: SnackbarCenter
|
||||||
|
#else
|
||||||
|
@ObservedObject var center: SnackbarCenter
|
||||||
|
#endif
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(Android)
|
||||||
|
EmptyView()
|
||||||
|
#else
|
||||||
|
VStack {
|
||||||
|
if let message = center.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)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
center.handleTap()
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.zIndex(999)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.9), value: center.current?.id)
|
||||||
|
.allowsHitTesting(center.current != nil)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func appNoAutoCap() -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
self
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
#else
|
||||||
|
self
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func appOTPKeyboard() -> some View {
|
||||||
|
#if os(iOS)
|
||||||
|
self
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
#else
|
||||||
|
self
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,29 @@ struct ContentView: View {
|
|||||||
@State var selectedTab: MainTab = .home
|
@State var selectedTab: MainTab = .home
|
||||||
private let tokenStore: TokenStore = DefaultTokenStore()
|
private let tokenStore: TokenStore = DefaultTokenStore()
|
||||||
@State var appState = AppState()
|
@State var appState = AppState()
|
||||||
|
#if os(Android)
|
||||||
|
@State var snackbarCenter = SnackbarCenter.shared
|
||||||
|
#else
|
||||||
|
@StateObject var snackbarCenter = SnackbarCenter.shared
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
ZStack(alignment: .top) {
|
||||||
switch root {
|
Group {
|
||||||
case .auth:
|
switch root {
|
||||||
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
case .auth:
|
||||||
case .main:
|
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||||
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
|
case .main:
|
||||||
|
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SnackbarOverlay(center: snackbarCenter)
|
||||||
}
|
}
|
||||||
.sheet(item: $appState.activeModal) { modal in
|
.sheet(item: $appState.activeModal) { modal in
|
||||||
switch modal {
|
switch modal {
|
||||||
case .addressPicker:
|
case .addressPicker:
|
||||||
AddressPickerModalView()
|
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
|
||||||
case .filters:
|
case .filters:
|
||||||
FiltersModalView()
|
FiltersModalView()
|
||||||
}
|
}
|
||||||
@@ -27,15 +36,40 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AddressPickerModalView: View {
|
struct AddressPickerModalView: View {
|
||||||
|
@Binding var appState: AppState
|
||||||
|
@Binding var selectedTab: MainTab
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
|
if let message = appState.address.onboardingMessage {
|
||||||
|
Text(message)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(AppColors.brandSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
}
|
||||||
|
|
||||||
Text("Selecionar endereco")
|
Text("Selecionar endereco")
|
||||||
.font(AppTypography.heading2)
|
.font(AppTypography.heading2)
|
||||||
Text("Fluxo de endereco sera implementado na etapa de checkout/perfil.")
|
Text("Fluxo de endereco sera implementado na etapa de checkout/perfil.")
|
||||||
.font(AppTypography.body)
|
.font(AppTypography.body)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
|
||||||
|
Button("Ir para Perfil > Endereços") {
|
||||||
|
selectedTab = .profile
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Adicionar novo endereço" : {
|
||||||
|
"comment" : "A button label that translates to \"Add new address\" in English.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ajuda" : {
|
"Ajuda" : {
|
||||||
"comment" : "A link to the help section of the app.",
|
"comment" : "A link to the help section of the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -114,6 +118,10 @@
|
|||||||
"comment" : "A welcome message displayed in the login view.",
|
"comment" : "A welcome message displayed in the login view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Buscando estabelecimentos próximos..." : {
|
||||||
|
"comment" : "A message indicating that the app is searching for nearby stores.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
"comment" : "Button title indicating that the operation should be cancelled",
|
"comment" : "Button title indicating that the operation should be cancelled",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -152,6 +160,10 @@
|
|||||||
"comment" : "A section header that lists available food categories.",
|
"comment" : "A section header that lists available food categories.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Conexão segura e criptografada" : {
|
||||||
|
"comment" : "A description below the \"Verificar e Entrar\" button, emphasizing the security of the login process.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Conteúdo da política de privacidade..." : {
|
"Conteúdo da política de privacidade..." : {
|
||||||
"comment" : "A placeholder text describing the content of the privacy policy.",
|
"comment" : "A placeholder text describing the content of the privacy policy.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -256,10 +268,6 @@
|
|||||||
"comment" : "A link that directs the user to the login screen.",
|
"comment" : "A link that directs the user to the login screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Enviado para %@" : {
|
|
||||||
"comment" : "A label below the \"OTP\" text, indicating that a code has been sent to the user's email address. The argument is the user's email address.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
|
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
|
||||||
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
|
"comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -369,6 +377,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Insira o código de 8 dígitos enviado" : {
|
||||||
|
"comment" : "A description below the text field where the user inputs their OTP code.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ir para Perfil > Endereços" : {
|
||||||
|
"comment" : "A button that navigates to the user profile screen when pressed.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Já tem uma conta?" : {
|
"Já tem uma conta?" : {
|
||||||
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
|
"comment" : "A question displayed below the \"Cadastrar e Receber Código\" button in the registration view. The text is a hyperlink to the login view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -403,6 +419,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Meus Endereços" : {
|
||||||
|
"comment" : "A label displayed at the top of the view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"comment" : "Placeholder title for the Name field in a form",
|
"comment" : "Placeholder title for the Name field in a form",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -433,8 +453,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Não recebeu o código?" : {
|
||||||
|
"comment" : "A question displayed below the button to re-send the OTP.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Não tem conta ainda?" : {
|
"Não tem conta ainda?" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Nenhum endereço cadastrado" : {
|
||||||
|
"comment" : "A message displayed when a user has no addresses saved.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Nenhum estabelecimento encontrado próximo à sua localização." : {
|
||||||
|
"comment" : "A message displayed when no nearby stores are found.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Notes" : {
|
"Notes" : {
|
||||||
"comment" : "Item editor form label for the Notes field",
|
"comment" : "Item editor form label for the Notes field",
|
||||||
@@ -474,8 +506,8 @@
|
|||||||
"comment" : "A heading displayed above a search bar in the home view.",
|
"comment" : "A heading displayed above a search bar in the home view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"OTP" : {
|
"para %@" : {
|
||||||
"comment" : "The title of a view that asks for a one-time password (OTP).",
|
"comment" : "A text label displaying the email address to which the one-time password (OTP) was sent. The text is truncated to fit within one line.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Pedidos" : {
|
"Pedidos" : {
|
||||||
@@ -528,6 +560,10 @@
|
|||||||
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
|
"comment" : "A description below the form fields in the registration view, instructing the user to fill them out to start.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"PRINCIPAL" : {
|
||||||
|
"comment" : "A label indicating that a particular address is the user's primary address.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Privacidade" : {
|
"Privacidade" : {
|
||||||
"comment" : "The title of the privacy policy screen.",
|
"comment" : "The title of the privacy policy screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -536,6 +572,10 @@
|
|||||||
"comment" : "A button label that says \"Receive Code\".",
|
"comment" : "A button label that says \"Receive Code\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Reenviar código" : {
|
||||||
|
"comment" : "A button that allows a user to request a new OTP code.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Save" : {
|
"Save" : {
|
||||||
"comment" : "Button title indicating that the current contents should be saved",
|
"comment" : "Button title indicating that the current contents should be saved",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -634,6 +674,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Tentar novamente" : {
|
||||||
|
"comment" : "A button label that attempts to reload data when an error occurs.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Termos de Uso" : {
|
"Termos de Uso" : {
|
||||||
"comment" : "A link to the app's \"Terms of Use\".",
|
"comment" : "A link to the app's \"Terms of Use\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -668,6 +712,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Verificação" : {
|
||||||
|
"comment" : "A title for the OTP verification screen.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Version %@ (%@)" : {
|
"Version %@ (%@)" : {
|
||||||
"comment" : "Settings label showing the current version of the app",
|
"comment" : "Settings label showing the current version of the app",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import Foundation
|
|||||||
#if canImport(FoundationNetworking)
|
#if canImport(FoundationNetworking)
|
||||||
import FoundationNetworking
|
import FoundationNetworking
|
||||||
#endif
|
#endif
|
||||||
|
#if canImport(LCEssentials) && os(iOS)
|
||||||
|
import LCEssentials
|
||||||
|
#endif
|
||||||
|
|
||||||
enum NetworkError: Error, LocalizedError {
|
enum NetworkError: Error, LocalizedError {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
@@ -36,7 +39,7 @@ enum NetworkError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ApiRequest {
|
struct ApiRequest: Sendable {
|
||||||
let path: String
|
let path: String
|
||||||
let method: String
|
let method: String
|
||||||
let module: ApiModule
|
let module: ApiModule
|
||||||
@@ -71,18 +74,117 @@ final class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||||
|
#if canImport(LCEssentials) && os(iOS)
|
||||||
|
return try await sendWithLCEssentials(request)
|
||||||
|
#else
|
||||||
|
return try await sendWithURLSession(request)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ApiClient {
|
||||||
|
#if canImport(LCEssentials) && os(iOS)
|
||||||
|
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||||
|
let urlString = try buildURL(path: request.path, query: request.queryItems).absoluteString
|
||||||
|
let method = request.method
|
||||||
|
let headers = buildHeaders(for: request)
|
||||||
|
let params = request.body
|
||||||
|
|
||||||
|
var attempt = 1
|
||||||
|
while attempt <= maxAttempts {
|
||||||
|
do {
|
||||||
|
let responseString = try await Self.performLCERequest(
|
||||||
|
url: urlString,
|
||||||
|
params: params,
|
||||||
|
method: method,
|
||||||
|
headers: headers
|
||||||
|
)
|
||||||
|
guard let data = responseString.data(using: .utf8) else {
|
||||||
|
throw NetworkError.decodeError("Resposta nao UTF-8")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw NetworkError.decodeError(String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
let mapped = mapError(error)
|
||||||
|
guard shouldRetry(mapped), attempt < maxAttempts else {
|
||||||
|
throw mapped
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: backoff(for: attempt, error: mapped))
|
||||||
|
attempt += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func performLCERequest(
|
||||||
|
url: String,
|
||||||
|
params: Data?,
|
||||||
|
method: String,
|
||||||
|
headers: [String: String]
|
||||||
|
) async throws -> String {
|
||||||
|
let httpMethod = toHTTPMethod(method)
|
||||||
|
return try await API.shared.request(
|
||||||
|
url: url,
|
||||||
|
params: params,
|
||||||
|
method: httpMethod,
|
||||||
|
headers: headers,
|
||||||
|
jsonEncoding: true,
|
||||||
|
debug: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func toHTTPMethod(_ method: String) -> httpMethod {
|
||||||
|
switch method.uppercased() {
|
||||||
|
case "POST": return .post
|
||||||
|
case "PUT": return .put
|
||||||
|
case "DELETE": return .delete
|
||||||
|
default: return .get
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapError(_ error: Error) -> NetworkError {
|
||||||
|
printError(title: "httpReqError", msg: error.localizedDescription)
|
||||||
|
if let network = error as? NetworkError {
|
||||||
|
return network
|
||||||
|
}
|
||||||
|
if let decoding = error as? DecodingError {
|
||||||
|
return .decodeError(String(describing: decoding))
|
||||||
|
}
|
||||||
|
|
||||||
|
let nsError = error as NSError
|
||||||
|
let apiMessage = serverMessage(from: nsError)
|
||||||
|
|
||||||
|
switch nsError.code {
|
||||||
|
case 401, 403:
|
||||||
|
return .unauthorized(apiMessage)
|
||||||
|
case 429:
|
||||||
|
return .rateLimited(nil)
|
||||||
|
case 400...599:
|
||||||
|
return .httpError(nsError.code, apiMessage)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if nsError.domain == NSURLErrorDomain {
|
||||||
|
return .transportError(nsError.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .transportError(nsError.localizedDescription)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func sendWithURLSession<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
||||||
let url = try buildURL(path: request.path, query: request.queryItems)
|
let url = try buildURL(path: request.path, query: request.queryItems)
|
||||||
var urlRequest = URLRequest(url: url)
|
var urlRequest = URLRequest(url: url)
|
||||||
urlRequest.httpMethod = request.method
|
urlRequest.httpMethod = request.method
|
||||||
urlRequest.httpBody = request.body
|
urlRequest.httpBody = request.body
|
||||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
||||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
|
|
||||||
if let token = ApiConfig.token(for: request.module) {
|
for (key, value) in buildHeaders(for: request) {
|
||||||
urlRequest.setValue(token, forHTTPHeaderField: "Atomenta-Token")
|
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
}
|
|
||||||
if request.requiresAuth, let jwt = tokenStore.jwt {
|
|
||||||
urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var attempt = 1
|
var attempt = 1
|
||||||
@@ -105,7 +207,7 @@ final class ApiClient {
|
|||||||
throw NetworkError.invalidResponse
|
throw NetworkError.invalidResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
|
func perform<T: Decodable>(_ request: URLRequest, as type: T.Type) async throws -> T {
|
||||||
let (data, response): (Data, URLResponse)
|
let (data, response): (Data, URLResponse)
|
||||||
do {
|
do {
|
||||||
(data, response) = try await session.data(for: request)
|
(data, response) = try await session.data(for: request)
|
||||||
@@ -137,7 +239,22 @@ final class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldRetry(_ error: NetworkError) -> Bool {
|
func buildHeaders(for request: ApiRequest) -> [String: String] {
|
||||||
|
var headers: [String: String] = [
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
]
|
||||||
|
|
||||||
|
if let token = ApiConfig.token(for: request.module) {
|
||||||
|
headers["Atomenta-Token"] = token
|
||||||
|
}
|
||||||
|
if request.requiresAuth, let jwt = tokenStore.jwt {
|
||||||
|
headers["Authorization"] = "Bearer \(jwt)"
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRetry(_ error: NetworkError) -> Bool {
|
||||||
switch error {
|
switch error {
|
||||||
case .rateLimited, .transportError:
|
case .rateLimited, .transportError:
|
||||||
return true
|
return true
|
||||||
@@ -148,7 +265,7 @@ final class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
|
func backoff(for attempt: Int, error: NetworkError) -> UInt64 {
|
||||||
if case .rateLimited(let retryAfter) = error, let retryAfter {
|
if case .rateLimited(let retryAfter) = error, let retryAfter {
|
||||||
return UInt64(retryAfter) * 1_000_000_000
|
return UInt64(retryAfter) * 1_000_000_000
|
||||||
}
|
}
|
||||||
@@ -156,7 +273,7 @@ final class ApiClient {
|
|||||||
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
|
return min(baseBackoffNanoseconds * multiplier, 2_000_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func serverMessage(from data: Data) -> String? {
|
func serverMessage(from data: Data) -> String? {
|
||||||
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
|
if let envelope = try? JSONDecoder().decode(ApiEnvelope<EmptyResult>.self, from: data) {
|
||||||
return envelope.message
|
return envelope.message
|
||||||
}
|
}
|
||||||
@@ -172,7 +289,29 @@ final class ApiClient {
|
|||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
|
#if canImport(LCEssentials) && os(iOS)
|
||||||
|
func serverMessage(from error: NSError) -> String? {
|
||||||
|
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String,
|
||||||
|
!reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
if let data = reason.data(using: .utf8),
|
||||||
|
let parsed = serverMessage(from: data),
|
||||||
|
!parsed.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
|
||||||
|
if let description = error.userInfo[NSLocalizedDescriptionKey] as? String,
|
||||||
|
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
!description.lowercased().contains("nsurlerrordomain") {
|
||||||
|
return description
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
|
||||||
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
|
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
|
||||||
throw NetworkError.invalidURL
|
throw NetworkError.invalidURL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum ApiModule {
|
enum ApiModule: Sendable {
|
||||||
case app
|
case app
|
||||||
case customer
|
case customer
|
||||||
case store
|
case store
|
||||||
|
|||||||
@@ -41,14 +41,28 @@ final class ApiService {
|
|||||||
|
|
||||||
// MARK: - Auth
|
// MARK: - Auth
|
||||||
|
|
||||||
func requestOtp(email: String) async throws -> ApiEnvelope<EmptyResult> {
|
func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope<RegistrationResult> {
|
||||||
let body = try JSONEncoder().encode(["email": email])
|
var payload: [String: String] = [
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"phoneNumber": phoneNumber
|
||||||
|
]
|
||||||
|
if let birthDate, birthDate.isEmpty == false {
|
||||||
|
payload["birthDate"] = birthDate
|
||||||
|
}
|
||||||
|
let body = try JSONEncoder().encode(payload)
|
||||||
|
let req = ApiRequest(path: "/api/customer", method: "POST", module: .customer, requiresAuth: false, body: body)
|
||||||
|
return try await send(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestOtp(email: String, phoneNumber: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||||
|
let body = try JSONEncoder().encode(["email": email, "phoneNumber": phoneNumber])
|
||||||
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
|
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
|
||||||
return try await send(req)
|
return try await send(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateOtp(email: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
|
func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
|
||||||
let body = try JSONEncoder().encode(["email": email, "otp": otp])
|
let body = try JSONEncoder().encode(["email": email, "phoneNumber": phoneNumber, "otp": otp])
|
||||||
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
|
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
|
||||||
let response: ApiEnvelope<LoginResult> = try await send(req)
|
let response: ApiEnvelope<LoginResult> = try await send(req)
|
||||||
if let token = response.result?.token {
|
if let token = response.result?.token {
|
||||||
@@ -84,6 +98,12 @@ final class ApiService {
|
|||||||
|
|
||||||
struct EmptyResult: Decodable {}
|
struct EmptyResult: Decodable {}
|
||||||
|
|
||||||
|
struct RegistrationResult: Decodable {
|
||||||
|
let id: String?
|
||||||
|
let name: String?
|
||||||
|
let email: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct LoginResult: Decodable {
|
struct LoginResult: Decodable {
|
||||||
let token: String
|
let token: String
|
||||||
let customer: CustomerProfile?
|
let customer: CustomerProfile?
|
||||||
@@ -94,6 +114,43 @@ struct CustomerProfile: Decodable {
|
|||||||
let name: String
|
let name: String
|
||||||
let email: String
|
let email: String
|
||||||
let phoneNumber: String?
|
let phoneNumber: String?
|
||||||
|
let profilePicture: String?
|
||||||
|
let addressBook: [CustomerAddress]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case email
|
||||||
|
case phoneNumber
|
||||||
|
case profilePicture
|
||||||
|
case addressBook = "address_book"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CustomerAddress: Decodable {
|
||||||
|
let id: String?
|
||||||
|
let label: String?
|
||||||
|
let address: String?
|
||||||
|
let number: String?
|
||||||
|
let complement: String?
|
||||||
|
let neighborhood: String?
|
||||||
|
let city: String?
|
||||||
|
let state: String?
|
||||||
|
let zipCode: String?
|
||||||
|
let latLong: [Double]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case label
|
||||||
|
case address
|
||||||
|
case number
|
||||||
|
case complement
|
||||||
|
case neighborhood
|
||||||
|
case city
|
||||||
|
case state
|
||||||
|
case zipCode
|
||||||
|
case latLong = "lat_long"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct StoreSummary: Decodable {
|
struct StoreSummary: Decodable {
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import Foundation
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class LocationService: NSObject {
|
final class LocationService: NSObject {
|
||||||
typealias LocationResult = Result<(Double, Double), LocationError>
|
typealias LocationResult = Result<(Double, Double), LocationError>
|
||||||
|
static let shared = LocationService()
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
enum LocationError: Error {
|
enum LocationError: Error {
|
||||||
@@ -35,13 +37,8 @@ final class LocationService: NSObject {
|
|||||||
|
|
||||||
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
|
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard CLLocationManager.locationServicesEnabled() else {
|
|
||||||
completion(.failure(.servicesDisabled))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.completion = completion
|
self.completion = completion
|
||||||
manager.requestWhenInUseAuthorization()
|
handleAuthorizationStatus(manager.authorizationStatus)
|
||||||
manager.requestLocation()
|
|
||||||
#else
|
#else
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
if defaults.bool(forKey: "location_permission_denied") {
|
if defaults.bool(forKey: "location_permission_denied") {
|
||||||
@@ -59,16 +56,55 @@ final class LocationService: NSObject {
|
|||||||
completion(.success((lat, lng)))
|
completion(.success((lat, lng)))
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cachedLocation() -> (Double, Double)? {
|
||||||
|
#if os(iOS)
|
||||||
|
guard let location = manager.location else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (location.coordinate.latitude, location.coordinate.longitude)
|
||||||
|
#else
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
guard let latRaw = defaults.string(forKey: "last_location_lat"),
|
||||||
|
let lngRaw = defaults.string(forKey: "last_location_lng"),
|
||||||
|
let lat = Double(latRaw),
|
||||||
|
let lng = Double(lngRaw) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (lat, lng)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
var hasResumed = false
|
||||||
|
|
||||||
|
func resumeOnce(_ value: (Double, Double)?) {
|
||||||
|
guard hasResumed == false else { return }
|
||||||
|
hasResumed = true
|
||||||
|
continuation.resume(returning: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLocation { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let coordinate):
|
||||||
|
resumeOnce(coordinate)
|
||||||
|
case .failure:
|
||||||
|
resumeOnce(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||||
|
resumeOnce(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
extension LocationService: CLLocationManagerDelegate {
|
extension LocationService: @preconcurrency CLLocationManagerDelegate {
|
||||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||||
let status = manager.authorizationStatus
|
handleAuthorizationStatus(manager.authorizationStatus)
|
||||||
if status == .denied || status == .restricted {
|
|
||||||
completion?(.failure(.denied))
|
|
||||||
completion = nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
@@ -81,5 +117,20 @@ extension LocationService: CLLocationManagerDelegate {
|
|||||||
completion?(.failure(.unavailable))
|
completion?(.failure(.unavailable))
|
||||||
completion = nil
|
completion = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) {
|
||||||
|
switch status {
|
||||||
|
case .notDetermined:
|
||||||
|
manager.requestWhenInUseAuthorization()
|
||||||
|
case .authorizedAlways, .authorizedWhenInUse:
|
||||||
|
manager.requestLocation()
|
||||||
|
case .denied, .restricted:
|
||||||
|
completion?(.failure(.denied))
|
||||||
|
completion = nil
|
||||||
|
@unknown default:
|
||||||
|
completion?(.failure(.unavailable))
|
||||||
|
completion = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ struct ProfileState {
|
|||||||
|
|
||||||
struct AddressState {
|
struct AddressState {
|
||||||
var selectedId: String? = nil
|
var selectedId: String? = nil
|
||||||
var display: String = "Rua das Flores, 123"
|
var display: String = "Defina seu endereco"
|
||||||
var latitude: Double? = nil
|
var latitude: Double? = nil
|
||||||
var longitude: Double? = nil
|
var longitude: Double? = nil
|
||||||
|
var onboardingMessage: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FavoritesState {
|
struct FavoritesState {
|
||||||
|
|||||||
99
pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift
Normal file
99
pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(Android)
|
||||||
|
@MainActor
|
||||||
|
final class SnackbarCenter {
|
||||||
|
static let shared = SnackbarCenter()
|
||||||
|
var current: SnackbarMessage?
|
||||||
|
|
||||||
|
func show(
|
||||||
|
title: String,
|
||||||
|
style: SnackbarStyle = .info,
|
||||||
|
icon: String? = nil,
|
||||||
|
duration: TimeInterval = 3.5,
|
||||||
|
action: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
action?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTap() {}
|
||||||
|
func dismiss(animated: Bool) {}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
@MainActor
|
||||||
|
final class SnackbarCenter: ObservableObject {
|
||||||
|
static let shared = SnackbarCenter()
|
||||||
|
|
||||||
|
@Published 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
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
if animated {
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum SnackbarStyle: Sendable {
|
||||||
|
case info
|
||||||
|
case success
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
|
||||||
|
var backgroundColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .info: return Color(hex: "#3B93F7")
|
||||||
|
case .success: return Color(hex: "#2E7D32")
|
||||||
|
case .warning: return Color(hex: "#C77700")
|
||||||
|
case .error: return Color(hex: "#C62828")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SnackbarMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let style: SnackbarStyle
|
||||||
|
let iconSystemName: String?
|
||||||
|
let duration: TimeInterval
|
||||||
|
let action: (() -> Void)?
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
enum Route: Hashable {
|
enum Route: Hashable {
|
||||||
case terms, policy
|
case terms, policy
|
||||||
case registration, loginEmail
|
case registration, loginEmail
|
||||||
|
case otp(email: String, phoneNumber: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AuthFlowView: View {
|
struct AuthFlowView: View {
|
||||||
@@ -27,385 +28,11 @@ struct AuthFlowView: View {
|
|||||||
case .registration:
|
case .registration:
|
||||||
RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
|
RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
|
||||||
case .loginEmail:
|
case .loginEmail:
|
||||||
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) })
|
||||||
|
case .otp(let email, let phoneNumber):
|
||||||
|
OtpView(email: email, phoneNumber: phoneNumber, root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoginView: View {
|
|
||||||
@Binding var root: RootFlow
|
|
||||||
@Binding var selectedTab: MainTab
|
|
||||||
let tokenStore: TokenStore
|
|
||||||
@Binding var appState: AppState
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
let navigate: (Route) -> Void
|
|
||||||
|
|
||||||
@ViewBuilder private var logoImage: some View {
|
|
||||||
#if os(Android)
|
|
||||||
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
|
|
||||||
.resizable()
|
|
||||||
#else
|
|
||||||
SwiftUI.Image("pedifoods")
|
|
||||||
.resizable()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder private var pinHeroImage: some View {
|
|
||||||
#if os(Android)
|
|
||||||
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 {
|
|
||||||
GeometryReader { geo in
|
|
||||||
let heroHeight = max(360, geo.size.height * 0.44)
|
|
||||||
|
|
||||||
ZStack(alignment: .top) {
|
|
||||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
|
|
||||||
pinHeroImage
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(height: heroHeight + 80)
|
|
||||||
.offset(y: -60)
|
|
||||||
.mask(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.black, .black, .black.opacity(0.0)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
VStack(spacing: 18) {
|
|
||||||
Spacer().frame(height: heroHeight - 100)
|
|
||||||
|
|
||||||
logoImage
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(height: 180)
|
|
||||||
|
|
||||||
Text("Descubra lorem ipsum ba bla")
|
|
||||||
.font(AppTypography.heading1)
|
|
||||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
|
|
||||||
Spacer().frame(height: 40)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
navigate(.loginEmail)
|
|
||||||
} label: {
|
|
||||||
PrimaryButtonLabel(title: "ENTRAR")
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 28)
|
|
||||||
.tint(AppColors.tertiary)
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Não tem conta ainda?")
|
|
||||||
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
|
|
||||||
Button("Criar conta") {
|
|
||||||
navigate(.registration)
|
|
||||||
}
|
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
}
|
|
||||||
.font(AppTypography.body)
|
|
||||||
|
|
||||||
Spacer().frame(height: 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ignoresSafeArea()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RegistrationView: View {
|
|
||||||
@Binding var root: RootFlow
|
|
||||||
@Binding var selectedTab: MainTab
|
|
||||||
let tokenStore: TokenStore
|
|
||||||
@Binding var appState: AppState
|
|
||||||
@State var name = ""
|
|
||||||
@State var email = ""
|
|
||||||
@State var phone = ""
|
|
||||||
@State var acceptedTerms = false
|
|
||||||
@Environment(\.dismiss) var dismiss
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
let navigate: (Route) -> Void
|
|
||||||
|
|
||||||
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)
|
|
||||||
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
|
|
||||||
.resizable()
|
|
||||||
#else
|
|
||||||
SwiftUI.Image("pedifoods")
|
|
||||||
.resizable()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
logoImage
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
|
|
||||||
Text("Crie sua conta")
|
|
||||||
.font(AppTypography.heading1)
|
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
|
||||||
|
|
||||||
Text("Preencha os dados abaixo para começar.")
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
|
||||||
.padding(.top, 8)
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
|
||||||
|
|
||||||
Toggle("", isOn: $acceptedTerms)
|
|
||||||
.labelsHidden()
|
|
||||||
.toggleStyle(SwitchToggleStyle(tint: AppColors.primary))
|
|
||||||
|
|
||||||
Group {
|
|
||||||
Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)")
|
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
|
||||||
.tint(AppColors.primary)
|
|
||||||
.environment(\.openURL, OpenURLAction { url in
|
|
||||||
guard url.scheme == "app" else { return .handled }
|
|
||||||
|
|
||||||
switch url.host {
|
|
||||||
case "terms":
|
|
||||||
navigate(.terms)
|
|
||||||
return .handled
|
|
||||||
case "policy":
|
|
||||||
navigate(.policy)
|
|
||||||
return .handled
|
|
||||||
default:
|
|
||||||
return .handled
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 16)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
|
|
||||||
PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) {
|
|
||||||
// TODO: integrar com ApiService e OTP
|
|
||||||
tokenStore.jwt = "demo-token"
|
|
||||||
appState.session.isAuthenticated = true
|
|
||||||
appState.session.jwt = "demo-token"
|
|
||||||
appState.profile.email = email
|
|
||||||
selectedTab = .home
|
|
||||||
root = .main
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.top, 6)
|
|
||||||
.disabled(!isFormValid)
|
|
||||||
.opacity(isFormValid ? 1.0 : 0.5)
|
|
||||||
.tint(AppColors.tertiary)
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Já tem uma conta?")
|
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.textPrimary)
|
|
||||||
NavigationLink("Entrar") {
|
|
||||||
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
|
||||||
}
|
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
}
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.padding(.top, 16)
|
|
||||||
|
|
||||||
Spacer().frame(height: 12)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, -40)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoginEmailView: View {
|
|
||||||
@Binding var root: RootFlow
|
|
||||||
@Binding var selectedTab: MainTab
|
|
||||||
let tokenStore: TokenStore
|
|
||||||
@Binding var appState: AppState
|
|
||||||
@State var email = ""
|
|
||||||
@State var phone = ""
|
|
||||||
@Environment(\.dismiss) var dismiss
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
|
|
||||||
@ViewBuilder private var logoImage: some View {
|
|
||||||
#if os(Android)
|
|
||||||
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
|
|
||||||
.resizable()
|
|
||||||
#else
|
|
||||||
SwiftUI.Image("pedifoods")
|
|
||||||
.resizable()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
|
|
||||||
logoImage
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(width: 120, height: 120)
|
|
||||||
|
|
||||||
Text("Boas-vindas!")
|
|
||||||
.font(AppTypography.heading1)
|
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
|
||||||
.padding(Edge.Set.top, 8)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
|
|
||||||
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
|
|
||||||
|
|
||||||
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
|
|
||||||
PrimaryButton(title: "Receber Código", image: Image(systemName: "arrow.right")) {
|
|
||||||
// TODO: integrar com ApiService e OTP
|
|
||||||
tokenStore.jwt = "demo-token"
|
|
||||||
appState.session.isAuthenticated = true
|
|
||||||
appState.session.jwt = "demo-token"
|
|
||||||
appState.profile.email = email
|
|
||||||
selectedTab = .home
|
|
||||||
root = .main
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 24)
|
|
||||||
.tint(AppColors.tertiary)
|
|
||||||
|
|
||||||
Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.gray)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 40)
|
|
||||||
.padding([.top, .bottom], 16)
|
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("Novo por aqui?")
|
|
||||||
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : Color.gray)
|
|
||||||
Text("Crie sua conta")
|
|
||||||
.foregroundStyle(AppColors.primary)
|
|
||||||
.onTapGesture {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.padding(.top, 8)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LoginField: View {
|
|
||||||
let icon: String
|
|
||||||
let placeholder: String
|
|
||||||
@Binding var text: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
Image(systemName: icon)
|
|
||||||
.foregroundStyle(Color.gray)
|
|
||||||
.frame(width: 28)
|
|
||||||
TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { }
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.foregroundColor(.black)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.frame(height: 52)
|
|
||||||
.background(Color.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
||||||
.overlay(
|
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
||||||
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OtpView: View {
|
|
||||||
let email: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Text("OTP")
|
|
||||||
.font(AppTypography.heading1)
|
|
||||||
.foregroundStyle(Color.white)
|
|
||||||
|
|
||||||
Text("Enviado para \(email)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.white.opacity(0.8))
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
.background(AppColors.backgroundLight)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
struct TermsOfUseView: View {
|
|
||||||
var body: some View {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text("Termos de Uso")
|
|
||||||
.font(AppTypography.heading1)
|
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
|
||||||
Text("Conteúdo dos termos de uso...")
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
}
|
|
||||||
.background((Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : UIColor(AppColors.backgroundLight) })).ignoresSafeArea())
|
|
||||||
.navigationTitle("Termos de Uso")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PrivacyPolicyView: View {
|
|
||||||
var body: some View {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Text("Política de Privacidade")
|
|
||||||
.font(AppTypography.heading1)
|
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
|
||||||
Text("Conteúdo da política de privacidade...")
|
|
||||||
.font(AppTypography.body)
|
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
|
||||||
}
|
|
||||||
.padding(24)
|
|
||||||
}
|
|
||||||
.background((Color(UIColor { trait in trait.userInterfaceStyle == .dark ? .black : UIColor(AppColors.backgroundLight) })).ignoresSafeArea())
|
|
||||||
.navigationTitle("Privacidade")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
84
pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift
Normal file
84
pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
func formatPhoneBR(_ input: String) -> String {
|
||||||
|
let digits = input.filter(\.isNumber)
|
||||||
|
let limited = String(digits.prefix(11))
|
||||||
|
let count = limited.count
|
||||||
|
guard count > 0 else { return "" }
|
||||||
|
|
||||||
|
if count <= 2 {
|
||||||
|
return "(\(limited)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let area = String(limited.prefix(2))
|
||||||
|
let remainder = String(limited.dropFirst(2))
|
||||||
|
|
||||||
|
if count <= 7 {
|
||||||
|
return "(\(area)) \(remainder)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstPart = String(remainder.prefix(5))
|
||||||
|
let secondPart = String(remainder.dropFirst(5))
|
||||||
|
return "(\(area)) \(firstPart)-\(secondPart)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePhoneNumberForAPI(_ input: String) -> String {
|
||||||
|
let digitsOnly = input.filter(\.isNumber)
|
||||||
|
if digitsOnly.count < 10 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if digitsOnly.hasPrefix("55") {
|
||||||
|
return "+\(digitsOnly)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "+55\(digitsOnly)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func userFacingAuthErrorMessage(_ error: Error) -> String {
|
||||||
|
if let serviceError = error as? ApiServiceError {
|
||||||
|
switch serviceError {
|
||||||
|
case .sessionExpired(let message):
|
||||||
|
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "Sua sessão expirou. Faça login novamente."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let networkError = error as? NetworkError {
|
||||||
|
switch networkError {
|
||||||
|
case .unauthorized(let message):
|
||||||
|
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return "Seu acesso expirou. Solicite um novo código para continuar."
|
||||||
|
case .httpError(let code, let message):
|
||||||
|
if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case 400:
|
||||||
|
return "Não foi possível validar seus dados. Revise as informações e tente novamente."
|
||||||
|
case 401, 403:
|
||||||
|
return "Seu acesso expirou. Solicite um novo código para continuar."
|
||||||
|
case 404:
|
||||||
|
return "Não encontramos seu cadastro com os dados informados."
|
||||||
|
case 429:
|
||||||
|
return "Muitas tentativas em sequência. Aguarde um instante e tente novamente."
|
||||||
|
case 500...599:
|
||||||
|
return "Nossos servidores estão instáveis no momento. Tente novamente em alguns minutos."
|
||||||
|
default:
|
||||||
|
return "Não foi possível concluir a operação agora. Tente novamente."
|
||||||
|
}
|
||||||
|
case .rateLimited:
|
||||||
|
return "Muitas tentativas em sequência. Aguarde um instante e tente novamente."
|
||||||
|
case .transportError:
|
||||||
|
return "Não foi possível se conectar ao servidor. Tente novamente."
|
||||||
|
case .invalidURL, .invalidResponse, .decodeError:
|
||||||
|
return "Ocorreu uma instabilidade ao processar sua solicitação. Tente novamente."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Não foi possível concluir a operação. Tente novamente."
|
||||||
|
}
|
||||||
47
pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
Normal file
47
pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TermsOfUseView: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Termos de Uso")
|
||||||
|
.font(AppTypography.heading1)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
Text("Conteúdo dos termos de uso...")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||||
|
.navigationTitle("Termos de Uso")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivacyPolicyView: View {
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Política de Privacidade")
|
||||||
|
.font(AppTypography.heading1)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
Text("Conteúdo da política de privacidade...")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
}
|
||||||
|
.background((colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea())
|
||||||
|
.navigationTitle("Privacidade")
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
148
pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
Normal file
148
pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginEmailView: View {
|
||||||
|
@Binding var root: RootFlow
|
||||||
|
@Binding var selectedTab: MainTab
|
||||||
|
let tokenStore: TokenStore
|
||||||
|
@Binding var appState: AppState
|
||||||
|
let navigate: (Route) -> Void
|
||||||
|
@State var email = ""
|
||||||
|
@State var phone = ""
|
||||||
|
@State var isLoading = false
|
||||||
|
@State var errorMessage: String?
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
@ViewBuilder private var logoImage: some View {
|
||||||
|
#if os(Android)
|
||||||
|
SwiftUI.Image("pedifoods")
|
||||||
|
.resizable()
|
||||||
|
#else
|
||||||
|
SwiftUI.Image("pedifoods")
|
||||||
|
.resizable()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
logoImage
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Text("Boas-vindas!")
|
||||||
|
.font(AppTypography.heading1)
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||||
|
.padding(Edge.Set.top, 8)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
LoginField(icon: "envelope", placeholder: "seu@email.com", text: $email)
|
||||||
|
|
||||||
|
LoginField(icon: "phone", placeholder: "(00) 00000-0000", text: $phone)
|
||||||
|
.onChange(of: phone) { _, newValue in
|
||||||
|
let masked = formatPhoneBR(newValue)
|
||||||
|
if masked != newValue {
|
||||||
|
phone = masked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
PrimaryButton(title: "Receber Código", image: Image(systemName: "arrow.right")) {
|
||||||
|
requestOtp()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.tint(AppColors.tertiary)
|
||||||
|
.disabled(isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty)
|
||||||
|
.opacity((isLoading || email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || normalizePhoneNumberForAPI(phone).isEmpty) ? 0.6 : 1.0)
|
||||||
|
|
||||||
|
Text("Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.gray)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
.padding([.top, .bottom], 16)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Novo por aqui?")
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : Color.gray)
|
||||||
|
Text("Crie sua conta")
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestOtp() {
|
||||||
|
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||||
|
guard !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return }
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let service = ApiService()
|
||||||
|
let response = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone)
|
||||||
|
await MainActor.run {
|
||||||
|
if response.error {
|
||||||
|
isLoading = false
|
||||||
|
let message = response.message ?? "Nao foi possivel enviar o codigo."
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
SnackbarCenter.shared.show(title: "Codigo enviado com sucesso.", style: .info, icon: "paperplane.fill", duration: 3.0)
|
||||||
|
appState.profile.email = sanitizedEmail
|
||||||
|
appState.profile.phone = phone
|
||||||
|
navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
let message = userFacingAuthErrorMessage(error)
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LoginField: View {
|
||||||
|
let icon: String
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.foregroundStyle(Color.gray)
|
||||||
|
.frame(width: 28)
|
||||||
|
TextField(text: $text, prompt: Text(placeholder).foregroundColor(Color.gray)) { }
|
||||||
|
.appNoAutoCap()
|
||||||
|
.foregroundColor(.black)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(Color.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(Color.black.opacity(0.06), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
Normal file
91
pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LoginView: View {
|
||||||
|
@Binding var root: RootFlow
|
||||||
|
@Binding var selectedTab: MainTab
|
||||||
|
let tokenStore: TokenStore
|
||||||
|
@Binding var appState: AppState
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
let navigate: (Route) -> Void
|
||||||
|
|
||||||
|
@ViewBuilder private var logoImage: some View {
|
||||||
|
#if os(Android)
|
||||||
|
SwiftUI.Image("pedifoods")
|
||||||
|
.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")
|
||||||
|
.resizable()
|
||||||
|
#else
|
||||||
|
SwiftUI.Image("pin_image_app")
|
||||||
|
.resizable()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let heroHeight = max(360, geo.size.height * 0.44)
|
||||||
|
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
(colorScheme == .dark ? Color.black : AppColors.backgroundLight)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
pinHeroImage
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(height: heroHeight + 80)
|
||||||
|
.offset(y: -60)
|
||||||
|
.mask(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.black, .black, .black.opacity(0.0)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(spacing: 18) {
|
||||||
|
Spacer().frame(height: heroHeight - 100)
|
||||||
|
|
||||||
|
logoImage
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 180)
|
||||||
|
|
||||||
|
Text("Descubra lorem ipsum ba bla")
|
||||||
|
.font(AppTypography.heading1)
|
||||||
|
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
|
||||||
|
Spacer().frame(height: 40)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
navigate(.loginEmail)
|
||||||
|
} label: {
|
||||||
|
PrimaryButtonLabel(title: "ENTRAR")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.tint(AppColors.tertiary)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Não tem conta ainda?")
|
||||||
|
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
|
||||||
|
Button("Criar conta") {
|
||||||
|
navigate(.registration)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
}
|
||||||
|
.font(AppTypography.body)
|
||||||
|
|
||||||
|
Spacer().frame(height: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
290
pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
Normal file
290
pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OtpView: View {
|
||||||
|
private let resendDelaySeconds = 45
|
||||||
|
|
||||||
|
let email: String
|
||||||
|
let phoneNumber: String
|
||||||
|
@Binding var root: RootFlow
|
||||||
|
@Binding var selectedTab: MainTab
|
||||||
|
let tokenStore: TokenStore
|
||||||
|
@Binding var appState: AppState
|
||||||
|
@State var otp = ""
|
||||||
|
@State var isLoading = false
|
||||||
|
@State var isResending = false
|
||||||
|
@State var resendCountdown = 45
|
||||||
|
@State var canResend = false
|
||||||
|
@State var errorMessage: String?
|
||||||
|
@State var countdownTask: Task<Void, Never>?
|
||||||
|
@FocusState var isOtpFocused: Bool
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
SwiftUI.Image("pedifoods")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 74, height: 74)
|
||||||
|
.padding(.top, 140)
|
||||||
|
|
||||||
|
Text("Verificação")
|
||||||
|
.font(AppTypography.heading1)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.padding(.top, 26)
|
||||||
|
|
||||||
|
Text("Insira o código de 8 dígitos enviado")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.padding(.top, 14)
|
||||||
|
|
||||||
|
Text("para \(email)")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.85)
|
||||||
|
.padding(.top, 2)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
otpGrid
|
||||||
|
TextField("", text: $otp)
|
||||||
|
.appOTPKeyboard()
|
||||||
|
.foregroundStyle(Color.clear)
|
||||||
|
.tint(Color.clear)
|
||||||
|
.focused($isOtpFocused)
|
||||||
|
.frame(maxWidth: CGFloat.greatestFiniteMagnitude, maxHeight: CGFloat.greatestFiniteMagnitude)
|
||||||
|
.opacity(0.02)
|
||||||
|
.onChange(of: otp) { _, newValue in
|
||||||
|
let digits = newValue.filter { $0.isNumber }
|
||||||
|
let trimmed = String(digits.prefix(8))
|
||||||
|
if trimmed != newValue {
|
||||||
|
otp = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 204)
|
||||||
|
.onTapGesture {
|
||||||
|
isOtpFocused = true
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 26)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("Não recebeu o código?")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
Button(resendButtonTitle) {
|
||||||
|
resendOtp()
|
||||||
|
}
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
.disabled(isResending || !canResend)
|
||||||
|
}
|
||||||
|
.padding(.top, 22)
|
||||||
|
|
||||||
|
PrimaryButton(title: "Verificar e Entrar") {
|
||||||
|
validateOtp()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 28)
|
||||||
|
.disabled(isLoading || otp.count != 8)
|
||||||
|
.opacity((isLoading || otp.count != 8) ? 0.6 : 1.0)
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.caption)
|
||||||
|
Text("Conexão segura e criptografada")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppColors.textMuted.opacity(0.8))
|
||||||
|
.padding(.top, 120)
|
||||||
|
.padding(.bottom, 18)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppColors.backgroundLight)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
isOtpFocused = true
|
||||||
|
startResendCooldown()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
countdownTask?.cancel()
|
||||||
|
countdownTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var resendButtonTitle: String {
|
||||||
|
if !canResend {
|
||||||
|
return String(format: "Reenviar em 00:%02d", resendCountdown)
|
||||||
|
}
|
||||||
|
return "Reenviar código"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var otpGrid: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
otpCell(index: 0)
|
||||||
|
otpCell(index: 1)
|
||||||
|
otpCell(index: 2)
|
||||||
|
otpCell(index: 3)
|
||||||
|
}
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
otpCell(index: 4)
|
||||||
|
otpCell(index: 5)
|
||||||
|
otpCell(index: 6)
|
||||||
|
otpCell(index: 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func otpCell(index: Int) -> some View {
|
||||||
|
let char = otpCharacter(at: index)
|
||||||
|
return ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.fill(Color.white)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
|
.stroke(Color(hex: "#EEF3FA"), lineWidth: 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let char {
|
||||||
|
Text(String(char))
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
} else {
|
||||||
|
Circle()
|
||||||
|
.fill(AppColors.textMuted.opacity(0.8))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 94)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func otpCharacter(at index: Int) -> Character? {
|
||||||
|
guard index < otp.count else { return nil }
|
||||||
|
return Array(otp)[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateOtp() {
|
||||||
|
let code = otp.filter(\.isNumber)
|
||||||
|
guard code.count == 8 else { return }
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let service = ApiService()
|
||||||
|
let response = try await service.validateOtp(email: email, phoneNumber: phoneNumber, otp: code)
|
||||||
|
await MainActor.run {
|
||||||
|
if response.error {
|
||||||
|
isLoading = false
|
||||||
|
let message = response.message ?? "Codigo invalido."
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
SnackbarCenter.shared.show(title: "Login realizado com sucesso.", style: .success, icon: "checkmark.seal.fill", duration: 2.5)
|
||||||
|
appState.session.isAuthenticated = true
|
||||||
|
appState.session.jwt = response.result?.token
|
||||||
|
appState.profile.email = email
|
||||||
|
routeAfterLogin()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
let message = userFacingAuthErrorMessage(error)
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func routeAfterLogin() {
|
||||||
|
if let cached = LocationService.shared.cachedLocation() {
|
||||||
|
appState.address.latitude = cached.0
|
||||||
|
appState.address.longitude = cached.1
|
||||||
|
selectedTab = .home
|
||||||
|
root = .main
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasConfiguredAddress() {
|
||||||
|
selectedTab = .home
|
||||||
|
root = .main
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
|
||||||
|
selectedTab = .profile
|
||||||
|
root = .main
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasConfiguredAddress() -> Bool {
|
||||||
|
if appState.address.selectedId != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if appState.address.latitude != nil, appState.address.longitude != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = appState.address.display
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
return normalized.isEmpty == false && normalized != "defina seu endereco"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resendOtp() {
|
||||||
|
guard canResend, !isResending else { return }
|
||||||
|
isResending = true
|
||||||
|
startResendCooldown()
|
||||||
|
errorMessage = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let service = ApiService()
|
||||||
|
let response = try await service.requestOtp(email: email, phoneNumber: phoneNumber)
|
||||||
|
await MainActor.run {
|
||||||
|
isResending = false
|
||||||
|
if response.error {
|
||||||
|
canResend = true
|
||||||
|
resendCountdown = 0
|
||||||
|
let message = response.message ?? "Nao foi possivel reenviar o código."
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0)
|
||||||
|
} else {
|
||||||
|
SnackbarCenter.shared.show(title: "Codigo reenviado.", style: .info, icon: "paperplane.fill", duration: 2.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isResending = false
|
||||||
|
canResend = true
|
||||||
|
resendCountdown = 0
|
||||||
|
let message = userFacingAuthErrorMessage(error)
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startResendCooldown() {
|
||||||
|
countdownTask?.cancel()
|
||||||
|
canResend = false
|
||||||
|
resendCountdown = resendDelaySeconds
|
||||||
|
countdownTask = Task {
|
||||||
|
var remaining = resendDelaySeconds
|
||||||
|
while !Task.isCancelled && remaining > 0 {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
|
remaining -= 1
|
||||||
|
await MainActor.run {
|
||||||
|
resendCountdown = max(remaining, 0)
|
||||||
|
canResend = remaining == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift
Normal file
173
pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RegistrationView: View {
|
||||||
|
@Binding var root: RootFlow
|
||||||
|
@Binding var selectedTab: MainTab
|
||||||
|
let tokenStore: TokenStore
|
||||||
|
@Binding var appState: AppState
|
||||||
|
@State var name = ""
|
||||||
|
@State var email = ""
|
||||||
|
@State var phone = ""
|
||||||
|
@State var acceptedTerms = false
|
||||||
|
@State var isLoading = false
|
||||||
|
@State var errorMessage: String?
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
let navigate: (Route) -> Void
|
||||||
|
|
||||||
|
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")
|
||||||
|
.resizable()
|
||||||
|
#else
|
||||||
|
SwiftUI.Image("pedifoods")
|
||||||
|
.resizable()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
(colorScheme == .dark ? Color.black : AppColors.backgroundLight).ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
logoImage
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 120, height: 120)
|
||||||
|
|
||||||
|
Text("Crie sua conta")
|
||||||
|
.font(AppTypography.heading1)
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||||
|
|
||||||
|
Text("Preencha os dados abaixo para começar.")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
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)
|
||||||
|
.onChange(of: phone) { _, newValue in
|
||||||
|
let masked = formatPhoneBR(newValue)
|
||||||
|
if masked != newValue {
|
||||||
|
phone = masked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Toggle("", isOn: $acceptedTerms)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(AppColors.primary)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
Text("Ao se cadastrar, você concorda com nossos [Termos de Uso](app://terms) e [Política de Privacidade](app://policy)")
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white : AppColors.textPrimary)
|
||||||
|
.tint(AppColors.primary)
|
||||||
|
.environment(\.openURL, OpenURLAction { url in
|
||||||
|
guard url.scheme == "app" else { return .handled }
|
||||||
|
|
||||||
|
switch url.host {
|
||||||
|
case "terms":
|
||||||
|
navigate(.terms)
|
||||||
|
return .handled
|
||||||
|
case "policy":
|
||||||
|
navigate(.policy)
|
||||||
|
return .handled
|
||||||
|
default:
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
PrimaryButton(title: "Cadastrar e Receber Código", image: Image(systemName: "arrow.right")) {
|
||||||
|
registerAndRequestOtp()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 6)
|
||||||
|
.disabled(!isFormValid || isLoading)
|
||||||
|
.opacity((!isFormValid || isLoading) ? 0.5 : 1.0)
|
||||||
|
.tint(AppColors.tertiary)
|
||||||
|
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Já tem uma conta?")
|
||||||
|
.foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.textPrimary)
|
||||||
|
NavigationLink("Entrar") {
|
||||||
|
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: navigate)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
}
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.padding(.top, 16)
|
||||||
|
|
||||||
|
Spacer().frame(height: 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, -40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerAndRequestOtp() {
|
||||||
|
let sanitizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let sanitizedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let normalizedPhone = normalizePhoneNumberForAPI(phone)
|
||||||
|
guard !sanitizedName.isEmpty, !sanitizedEmail.isEmpty, !normalizedPhone.isEmpty else { return }
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let service = ApiService()
|
||||||
|
let registration = try await service.registerCustomer(
|
||||||
|
name: sanitizedName,
|
||||||
|
email: sanitizedEmail,
|
||||||
|
phoneNumber: normalizedPhone
|
||||||
|
)
|
||||||
|
if registration.error {
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
let message = registration.message ?? "Nao foi possivel concluir o cadastro."
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let otp = try await service.requestOtp(email: sanitizedEmail, phoneNumber: normalizedPhone)
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
if otp.error {
|
||||||
|
let message = otp.message ?? "Cadastro concluido, mas nao foi possivel enviar o codigo."
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .warning, icon: "exclamationmark.triangle.fill", duration: 4.0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SnackbarCenter.shared.show(title: "Cadastro concluido. Codigo enviado.", style: .success, icon: "checkmark.seal.fill", duration: 3.0)
|
||||||
|
appState.profile.email = sanitizedEmail
|
||||||
|
appState.profile.phone = phone
|
||||||
|
navigate(.otp(email: sanitizedEmail, phoneNumber: normalizedPhone))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run {
|
||||||
|
isLoading = false
|
||||||
|
let message = userFacingAuthErrorMessage(error)
|
||||||
|
errorMessage = message
|
||||||
|
SnackbarCenter.shared.show(title: message, style: .error, icon: "xmark.octagon.fill", duration: 4.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ struct HomeView: View {
|
|||||||
@State var selectedCategory = "Stores"
|
@State var selectedCategory = "Stores"
|
||||||
@State var scrollOffset: CGFloat = 0
|
@State var scrollOffset: CGFloat = 0
|
||||||
@State var hasRequestedLocation = false
|
@State var hasRequestedLocation = false
|
||||||
let locationService = LocationService()
|
@State var isLoadingStores = false
|
||||||
|
@State var storesError: String? = nil
|
||||||
|
@State var stores: [StoreSummary] = []
|
||||||
|
|
||||||
private let categories: [CategoryModel] = [
|
private let categories: [CategoryModel] = [
|
||||||
.init(title: "Stores", systemIcon: "storefront"),
|
.init(title: "Stores", systemIcon: "storefront"),
|
||||||
@@ -19,52 +21,6 @@ struct HomeView: View {
|
|||||||
.init(title: "Dessert", systemIcon: "cup.and.saucer")
|
.init(title: "Dessert", systemIcon: "cup.and.saucer")
|
||||||
]
|
]
|
||||||
|
|
||||||
private let featuredStores: [FeaturedStoreCardModel] = [
|
|
||||||
.init(
|
|
||||||
name: "Burger Kingdom",
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: "1.2k",
|
|
||||||
distance: "1.2 km",
|
|
||||||
category: "Fast Food",
|
|
||||||
promoText: "10% OFF",
|
|
||||||
isFavorite: false,
|
|
||||||
iconName: "takeoutbag.and.cup.and.straw"
|
|
||||||
),
|
|
||||||
.init(
|
|
||||||
name: "Sushi House",
|
|
||||||
rating: 4.8,
|
|
||||||
reviews: "840",
|
|
||||||
distance: "2.2 km",
|
|
||||||
category: "Japanese",
|
|
||||||
promoText: nil,
|
|
||||||
isFavorite: true,
|
|
||||||
iconName: "fork.knife"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
private let nearbyStores: [FeaturedStoreCardModel] = [
|
|
||||||
.init(
|
|
||||||
name: "Pizza Prime",
|
|
||||||
rating: 4.7,
|
|
||||||
reviews: "510",
|
|
||||||
distance: "1.8 km",
|
|
||||||
category: "Pizza",
|
|
||||||
promoText: nil,
|
|
||||||
isFavorite: false,
|
|
||||||
iconName: "takeoutbag.and.cup.and.straw"
|
|
||||||
),
|
|
||||||
.init(
|
|
||||||
name: "Aoyama",
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: "2.4k",
|
|
||||||
distance: "3.1 km",
|
|
||||||
category: "Japanese",
|
|
||||||
promoText: nil,
|
|
||||||
isFavorite: true,
|
|
||||||
iconName: "fork.knife"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
private let specials: [SpecialOfferCardModel] = [
|
private let specials: [SpecialOfferCardModel] = [
|
||||||
.init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
.init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
|
||||||
.init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
|
.init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
|
||||||
@@ -95,10 +51,6 @@ struct HomeView: View {
|
|||||||
.padding(.top, headerPadding + 16)
|
.padding(.top, headerPadding + 16)
|
||||||
.padding(.bottom, 24)
|
.padding(.bottom, 24)
|
||||||
}
|
}
|
||||||
.coordinateSpace(name: "scroll")
|
|
||||||
.onPreferenceChange(ScrollOffsetKey.self) { value in
|
|
||||||
scrollOffset = min(0, value)
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
header(collapseProgress: collapseProgress, height: headerHeight)
|
header(collapseProgress: collapseProgress, height: headerHeight)
|
||||||
@@ -110,14 +62,8 @@ struct HomeView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
if hasRequestedLocation == false {
|
if hasRequestedLocation == false {
|
||||||
hasRequestedLocation = true
|
hasRequestedLocation = true
|
||||||
locationService.requestLocation { result in
|
Task {
|
||||||
switch result {
|
await bootstrapStoresFlow()
|
||||||
case .success(let coordinate):
|
|
||||||
appState.address.latitude = coordinate.0
|
|
||||||
appState.address.longitude = coordinate.1
|
|
||||||
case .failure:
|
|
||||||
appState.address.display = "Defina seu endereco"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +78,7 @@ struct HomeView: View {
|
|||||||
section(title: "Featured") {
|
section(title: "Featured") {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
ForEach(featuredStores) { store in
|
ForEach(featuredStoresCards) { store in
|
||||||
FeaturedStoreCard(store: store)
|
FeaturedStoreCard(store: store)
|
||||||
.frame(width: 190)
|
.frame(width: 190)
|
||||||
}
|
}
|
||||||
@@ -154,12 +100,41 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
section(title: "Near you") {
|
section(title: "Near you") {
|
||||||
VStack(spacing: 16) {
|
if isLoadingStores {
|
||||||
ForEach(nearbyStores) { store in
|
HStack {
|
||||||
FeaturedStoreCard(store: store)
|
ProgressView()
|
||||||
|
Text("Buscando estabelecimentos próximos...")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
} else if let storesError {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(storesError)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
Button("Tentar novamente") {
|
||||||
|
Task {
|
||||||
|
await bootstrapStoresFlow(forceLocationRefresh: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
.foregroundStyle(AppColors.primary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
} else if nearbyStoreCards.isEmpty {
|
||||||
|
Text("Nenhum estabelecimento encontrado próximo à sua localização.")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ForEach(nearbyStoreCards) { store in
|
||||||
|
FeaturedStoreCard(store: store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,7 +197,6 @@ struct HomeView: View {
|
|||||||
Text("O que vai querer pedir hoje?")
|
Text("O que vai querer pedir hoje?")
|
||||||
.font(AppTypography.heading1)
|
.font(AppTypography.heading1)
|
||||||
.foregroundStyle(AppColors.textInverse)
|
.foregroundStyle(AppColors.textInverse)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.opacity(titleOpacity)
|
.opacity(titleOpacity)
|
||||||
.offset(y: collapseProgress * -20)
|
.offset(y: collapseProgress * -20)
|
||||||
|
|
||||||
@@ -294,6 +268,120 @@ struct HomeView: View {
|
|||||||
.offset(x: 70, y: 10)
|
.offset(x: 70, y: 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sortedStores: [StoreSummary] {
|
||||||
|
stores.sorted { lhs, rhs in
|
||||||
|
(lhs.distance ?? .greatestFiniteMagnitude) < (rhs.distance ?? .greatestFiniteMagnitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var featuredStoresCards: [FeaturedStoreCardModel] {
|
||||||
|
Array(sortedStores.prefix(6)).map(mapStoreToCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nearbyStoreCards: [FeaturedStoreCardModel] {
|
||||||
|
Array(sortedStores.prefix(20)).map(mapStoreToCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mapStoreToCard(_ store: StoreSummary) -> FeaturedStoreCardModel {
|
||||||
|
FeaturedStoreCardModel(
|
||||||
|
name: store.name,
|
||||||
|
rating: store.rating ?? 0,
|
||||||
|
reviews: "0",
|
||||||
|
distance: formatDistance(store.distance),
|
||||||
|
category: store.category ?? "Loja",
|
||||||
|
promoText: nil,
|
||||||
|
isFavorite: appState.favorites.storeIds.contains(store.id),
|
||||||
|
iconName: "storefront"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDistance(_ distance: Double?) -> String {
|
||||||
|
guard let distance else { return "Distância indisponível" }
|
||||||
|
if distance >= 1 {
|
||||||
|
return String(format: "%.1f km", distance)
|
||||||
|
}
|
||||||
|
return "\(Int(distance * 1000)) m"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func bootstrapStoresFlow(forceLocationRefresh: Bool = false) async {
|
||||||
|
isLoadingStores = true
|
||||||
|
storesError = nil
|
||||||
|
|
||||||
|
guard let coordinate = await resolveCoordinates(forceRefresh: forceLocationRefresh) else {
|
||||||
|
isLoadingStores = false
|
||||||
|
stores = []
|
||||||
|
storesError = "Defina um endereço para visualizar os estabelecimentos mais próximos."
|
||||||
|
appState.address.onboardingMessage = "Vamos criar um endereço para poder visualizar os estabelecimentos mais próximos de você?"
|
||||||
|
appState.activeModal = .addressPicker
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await ApiService().listStores(lat: coordinate.0, lng: coordinate.1)
|
||||||
|
isLoadingStores = false
|
||||||
|
if response.error {
|
||||||
|
stores = []
|
||||||
|
storesError = response.message ?? "Não foi possível carregar os estabelecimentos."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stores = response.result ?? []
|
||||||
|
storesError = nil
|
||||||
|
} catch {
|
||||||
|
isLoadingStores = false
|
||||||
|
stores = []
|
||||||
|
storesError = storesUserMessage(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func resolveCoordinates(forceRefresh: Bool) async -> (Double, Double)? {
|
||||||
|
if !forceRefresh {
|
||||||
|
if let lat = appState.address.latitude, let lng = appState.address.longitude {
|
||||||
|
return (lat, lng)
|
||||||
|
}
|
||||||
|
if let cached = LocationService.shared.cachedLocation() {
|
||||||
|
appState.address.latitude = cached.0
|
||||||
|
appState.address.longitude = cached.1
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
if hasConfiguredAddress() == false {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let coordinate = await LocationService.shared.requestLocationAsync(timeoutSeconds: 3)
|
||||||
|
if let coordinate {
|
||||||
|
appState.address.latitude = coordinate.0
|
||||||
|
appState.address.longitude = coordinate.1
|
||||||
|
}
|
||||||
|
return coordinate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasConfiguredAddress() -> Bool {
|
||||||
|
if appState.address.selectedId != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if appState.address.latitude != nil, appState.address.longitude != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = appState.address.display
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
|
||||||
|
return normalized.isEmpty == false && normalized != "defina seu endereco"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func storesUserMessage(_ error: Error) -> String {
|
||||||
|
if let service = error as? ApiServiceError {
|
||||||
|
return service.errorDescription ?? "Não foi possível carregar os estabelecimentos."
|
||||||
|
}
|
||||||
|
if let network = error as? NetworkError {
|
||||||
|
return network.errorDescription ?? "Não foi possível carregar os estabelecimentos."
|
||||||
|
}
|
||||||
|
return "Não foi possível carregar os estabelecimentos."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CategoryModel: Identifiable {
|
struct CategoryModel: Identifiable {
|
||||||
@@ -332,8 +420,7 @@ struct SearchBar: View {
|
|||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
.foregroundStyle(AppColors.textMuted)
|
.foregroundStyle(AppColors.textMuted)
|
||||||
TextField(placeholder, text: $text)
|
TextField(placeholder, text: $text)
|
||||||
.textInputAutocapitalization(.never)
|
.appNoAutoCap()
|
||||||
.autocorrectionDisabled()
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: onFilterTap) {
|
Button(action: onFilterTap) {
|
||||||
Image(systemName: "slider.horizontal.3")
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ struct ProfileView: View {
|
|||||||
@Binding var selectedTab: MainTab
|
@Binding var selectedTab: MainTab
|
||||||
let tokenStore: TokenStore
|
let tokenStore: TokenStore
|
||||||
@Binding var appState: AppState
|
@Binding var appState: AppState
|
||||||
|
@State var openAddressesOnboarding = false
|
||||||
|
@State var onboardingMessage: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
@@ -16,7 +18,7 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink("Enderecos") {
|
NavigationLink("Enderecos") {
|
||||||
Text("Enderecos")
|
AddressesView(message: nil, appState: $appState)
|
||||||
}
|
}
|
||||||
|
|
||||||
NavigationLink("Ajuda") {
|
NavigationLink("Ajuda") {
|
||||||
@@ -26,6 +28,13 @@ struct ProfileView: View {
|
|||||||
.font(AppTypography.body)
|
.font(AppTypography.body)
|
||||||
.foregroundStyle(AppColors.textPrimary)
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
NavigationLink(isActive: $openAddressesOnboarding) {
|
||||||
|
AddressesView(message: onboardingMessage, appState: $appState)
|
||||||
|
} label: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.hidden()
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
SecondaryButton(title: "Sair") {
|
SecondaryButton(title: "Sair") {
|
||||||
@@ -39,6 +48,14 @@ struct ProfileView: View {
|
|||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
|
.onAppear {
|
||||||
|
guard let message = appState.address.onboardingMessage else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onboardingMessage = message
|
||||||
|
appState.address.onboardingMessage = nil
|
||||||
|
openAddressesOnboarding = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
@@ -63,6 +80,268 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AddressesView: View {
|
||||||
|
let message: String?
|
||||||
|
@Binding var appState: AppState
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State var isLoading = false
|
||||||
|
@State var errorMessage: String? = nil
|
||||||
|
@State var addresses: [CustomerAddress] = []
|
||||||
|
|
||||||
|
let tabBarClearance: CGFloat = 96
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
AppColors.backgroundLight
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
header
|
||||||
|
|
||||||
|
if let message {
|
||||||
|
Text(message)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.background(AppColors.brandSoft)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.padding(.top, 24)
|
||||||
|
} else if let errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top, 24)
|
||||||
|
} else if addresses.isEmpty {
|
||||||
|
Text("Nenhum endereço cadastrado")
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.padding(.top, 24)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(addresses.enumerated()), id: \.offset) { index, address in
|
||||||
|
AddressCard(item: addressToListItem(address, isPrimary: index == 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
bottomOverlay
|
||||||
|
.padding(.bottom, tabBarClearance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarBackButtonHidden(true)
|
||||||
|
.toolbar(.hidden, for: .navigationBar)
|
||||||
|
.onAppear {
|
||||||
|
if isLoading == false, addresses.isEmpty {
|
||||||
|
Task {
|
||||||
|
await loadAddresses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var header: some View {
|
||||||
|
ZStack {
|
||||||
|
Text("Meus Endereços")
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button(action: { dismiss() }) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.system(size: 24, weight: .semibold))
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.frame(width: 52, height: 52)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: .black.opacity(0.06), radius: 8, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bottomOverlay: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppColors.backgroundLight)
|
||||||
|
.frame(height: 136)
|
||||||
|
|
||||||
|
Button(action: {}) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "mappin.circle.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
Text("Adicionar novo endereço")
|
||||||
|
.font(AppTypography.heading3)
|
||||||
|
}
|
||||||
|
.foregroundStyle(AppColors.textInverse)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.background(AppColors.primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addressToListItem(_ address: CustomerAddress, isPrimary: Bool) -> AddressListItem {
|
||||||
|
let title = address.label?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? (address.label ?? "Endereço") : "Endereço"
|
||||||
|
let line1 = [address.address, address.number]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { $0.isEmpty == false }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
let line2 = [address.neighborhood, address.city, address.state]
|
||||||
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { $0.isEmpty == false }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
let detail = [line1, line2]
|
||||||
|
.filter { $0.isEmpty == false }
|
||||||
|
.joined(separator: " - ")
|
||||||
|
|
||||||
|
return AddressListItem(
|
||||||
|
title: title,
|
||||||
|
detail: detail.isEmpty ? "Endereço sem detalhes" : detail,
|
||||||
|
icon: iconName(for: title),
|
||||||
|
isPrimary: isPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func iconName(for label: String) -> String {
|
||||||
|
let normalized = label.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||||
|
if normalized.contains("casa") {
|
||||||
|
return "house.fill"
|
||||||
|
}
|
||||||
|
if normalized.contains("trabalho") {
|
||||||
|
return "briefcase.fill"
|
||||||
|
}
|
||||||
|
return "mappin.and.ellipse"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func loadAddresses() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let service = ApiService()
|
||||||
|
let response = try await service.profile()
|
||||||
|
guard response.error == false else {
|
||||||
|
errorMessage = response.message ?? "Não foi possível carregar os endereços."
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses = response.result?.addressBook ?? []
|
||||||
|
if let first = addresses.first {
|
||||||
|
appState.address.selectedId = first.id
|
||||||
|
appState.address.display = first.label ?? "Defina seu endereco"
|
||||||
|
if let lat = first.latLong?.first, let lng = first.latLong?.dropFirst().first {
|
||||||
|
appState.address.latitude = lat
|
||||||
|
appState.address.longitude = lng
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddressCard: View {
|
||||||
|
let item: AddressListItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
icon
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(item.title)
|
||||||
|
.font(AppTypography.heading2)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
|
||||||
|
if item.isPrimary {
|
||||||
|
Text("PRINCIPAL")
|
||||||
|
.font(AppTypography.overline)
|
||||||
|
.foregroundStyle(AppColors.textPrimary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(AppColors.tertiary)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.lineLimit(1)
|
||||||
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.detail)
|
||||||
|
.font(AppTypography.body)
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(AppColors.backgroundLight)
|
||||||
|
.frame(width: 1, height: 96)
|
||||||
|
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Button(action: {}) {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(action: {}) {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 40)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
.background(AppColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: some View {
|
||||||
|
Image(systemName: item.icon)
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(item.isPrimary ? AppColors.primary : AppColors.textPrimary)
|
||||||
|
.frame(width: 84, height: 84)
|
||||||
|
.background(item.isPrimary ? AppColors.brandSoft : AppColors.backgroundLight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AddressListItem: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let icon: String
|
||||||
|
let isPrimary: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct OrdersView: View {
|
struct OrdersView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
|||||||
7
pedi-foods/Tests/PediFoodsTests/SmokeTests.swift
Normal file
7
pedi-foods/Tests/PediFoodsTests/SmokeTests.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import PediFoods
|
||||||
|
|
||||||
|
@Test("Smoke")
|
||||||
|
func smoke() {
|
||||||
|
#expect(Bool(true))
|
||||||
|
}
|
||||||
256
pedi-foods/scripts/android-run.sh
Executable file
256
pedi-foods/scripts/android-run.sh
Executable file
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ANDROID_SDK_ROOT_DEFAULT="$HOME/Library/Android/sdk"
|
||||||
|
EMULATOR_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/emulator/emulator"
|
||||||
|
ADB_BIN_DEFAULT="$ANDROID_SDK_ROOT_DEFAULT/platform-tools/adb"
|
||||||
|
|
||||||
|
AVD_NAME="${1:-lc_arm64}"
|
||||||
|
BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-120}"
|
||||||
|
ADB_CONNECT_TIMEOUT_SECONDS="${ADB_CONNECT_TIMEOUT_SECONDS:-90}"
|
||||||
|
APP_ID_DEFAULT="com.br.pedifoods.app"
|
||||||
|
|
||||||
|
if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
|
||||||
|
EMULATOR_BIN="$ANDROID_SDK_ROOT/emulator/emulator"
|
||||||
|
ADB_BIN="$ANDROID_SDK_ROOT/platform-tools/adb"
|
||||||
|
else
|
||||||
|
EMULATOR_BIN="$EMULATOR_BIN_DEFAULT"
|
||||||
|
ADB_BIN="$ADB_BIN_DEFAULT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$ANDROID_SDK_ROOT_DEFAULT}}"
|
||||||
|
ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$ANDROID_HOME}"
|
||||||
|
export ANDROID_HOME
|
||||||
|
export ANDROID_SDK_ROOT
|
||||||
|
|
||||||
|
if [[ ! -x "$EMULATOR_BIN" ]]; then
|
||||||
|
echo "Erro: emulator não encontrado em '$EMULATOR_BIN'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$ADB_BIN" ]]; then
|
||||||
|
echo "Erro: adb não encontrado em '$ADB_BIN'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v skip >/dev/null 2>&1; then
|
||||||
|
echo "Erro: comando 'skip' não encontrado no PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! "$EMULATOR_BIN" -list-avds | grep -qx "$AVD_NAME"; then
|
||||||
|
echo "Erro: AVD '$AVD_NAME' não existe. Disponíveis:"
|
||||||
|
"$EMULATOR_BIN" -list-avds
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_SERIAL="$("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}')"
|
||||||
|
EMULATOR_PID=""
|
||||||
|
|
||||||
|
"$ADB_BIN" start-server >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [[ -z "$TARGET_SERIAL" ]]; then
|
||||||
|
echo "Iniciando emulador: $AVD_NAME"
|
||||||
|
nohup "$EMULATOR_BIN" -avd "$AVD_NAME" >/tmp/skip-android-emulator.log 2>&1 &
|
||||||
|
EMULATOR_PID="$!"
|
||||||
|
sleep 2
|
||||||
|
else
|
||||||
|
BOOTED="$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
|
||||||
|
if [[ "$BOOTED" == "1" ]]; then
|
||||||
|
echo "Dispositivo já pronto."
|
||||||
|
else
|
||||||
|
echo "Dispositivo conectado, aguardando finalização do boot."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$TARGET_SERIAL" ]]; then
|
||||||
|
echo "Aguardando ADB conectar..."
|
||||||
|
CONNECT_START_TS="$(date +%s)"
|
||||||
|
while true; do
|
||||||
|
TARGET_SERIAL="$("$ADB_BIN" devices | awk 'NR>1 && $2=="device" {print $1; exit}')"
|
||||||
|
if [[ -n "$TARGET_SERIAL" ]]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
NOW_TS="$(date +%s)"
|
||||||
|
if (( NOW_TS - CONNECT_START_TS >= ADB_CONNECT_TIMEOUT_SECONDS )); then
|
||||||
|
echo "Timeout aguardando ADB conectar (${ADB_CONNECT_TIMEOUT_SECONDS}s)."
|
||||||
|
echo "Estado atual do adb:"
|
||||||
|
"$ADB_BIN" devices || true
|
||||||
|
if [[ -n "$EMULATOR_PID" ]] && ! kill -0 "$EMULATOR_PID" 2>/dev/null; then
|
||||||
|
echo "Processo do emulador encerrou antes da conexão."
|
||||||
|
fi
|
||||||
|
echo "Últimas linhas do log do emulador (/tmp/skip-android-emulator.log):"
|
||||||
|
tail -n 80 /tmp/skip-android-emulator.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Aguardando boot do Android..."
|
||||||
|
START_TS="$(date +%s)"
|
||||||
|
until [[ "$("$ADB_BIN" -s "$TARGET_SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" == "1" ]]; do
|
||||||
|
NOW_TS="$(date +%s)"
|
||||||
|
if (( NOW_TS - START_TS >= BOOT_TIMEOUT_SECONDS )); then
|
||||||
|
echo "Timeout aguardando boot do Android (${BOOT_TIMEOUT_SECONDS}s) no dispositivo $TARGET_SERIAL."
|
||||||
|
"$ADB_BIN" devices || true
|
||||||
|
tail -n 80 /tmp/skip-android-emulator.log || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_ID="$(sed -nE 's/^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER[[:space:]]*=[[:space:]]*([^[:space:]]+).*/\1/p' "$PROJECT_DIR/Skip.env" | head -n1)"
|
||||||
|
APP_ID="${APP_ID:-$APP_ID_DEFAULT}"
|
||||||
|
REQUIRE_FRESH_APK="${REQUIRE_FRESH_APK:-1}"
|
||||||
|
ALLOW_INSTALLED_FALLBACK="${ALLOW_INSTALLED_FALLBACK:-1}"
|
||||||
|
|
||||||
|
repair_skip_packages_links() {
|
||||||
|
local swift_dir="$PROJECT_DIR/.build/plugins/outputs/pedi-foods/PediFoods/destination/skipstone/PediFoods/src/main/swift"
|
||||||
|
local packages_dir="$swift_dir/Packages"
|
||||||
|
if [[ ! -d "$packages_dir" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
for link_path in "$packages_dir"/*; do
|
||||||
|
[[ -e "$link_path" || -L "$link_path" ]] || continue
|
||||||
|
local pkg_name
|
||||||
|
pkg_name="$(basename "$link_path")"
|
||||||
|
local pkg_manifest="$link_path/Package.swift"
|
||||||
|
if [[ -f "$pkg_manifest" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local checkout_path="$PROJECT_DIR/.build/checkouts/$pkg_name"
|
||||||
|
if [[ -f "$checkout_path/Package.swift" ]]; then
|
||||||
|
echo "Reparando link de pacote: $pkg_name -> $checkout_path"
|
||||||
|
rm -rf "$link_path"
|
||||||
|
ln -s "$checkout_path" "$link_path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
clean_skip_generated_outputs() {
|
||||||
|
echo "Limpando artefatos gerados do Skip para recuperação..."
|
||||||
|
rm -rf \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-android-bridge" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-bridge" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-foundation" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-fuse-ui" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-lib" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-model" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-ui" \
|
||||||
|
"$PROJECT_DIR/.build/plugins/outputs/skip-unit"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_skip_export() {
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
if skip export --debug --no-ios --android; then
|
||||||
|
repair_skip_packages_links
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Aviso: skip export falhou na primeira tentativa."
|
||||||
|
clean_skip_generated_outputs
|
||||||
|
repair_skip_packages_links
|
||||||
|
|
||||||
|
echo "Tentando skip export novamente após limpeza de cache..."
|
||||||
|
if skip export --debug --no-ios --android; then
|
||||||
|
repair_skip_packages_links
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
repair_skip_packages_links
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
repair_skip_packages_links
|
||||||
|
|
||||||
|
find_apk_path() {
|
||||||
|
if [[ -f "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk" ]]; then
|
||||||
|
echo "$PROJECT_DIR/.build/Android/app/outputs/apk/debug/app-debug.apk"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -f "$PROJECT_DIR/Android/app/build/outputs/apk/debug/app-debug.apk" ]]; then
|
||||||
|
echo "$PROJECT_DIR/Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
find "$PROJECT_DIR/.build/Android" "$PROJECT_DIR/Android/app/build/outputs/apk/debug" -type f -name '*.apk' 2>/dev/null | head -n1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
APK_PATH="$(find_apk_path)"
|
||||||
|
|
||||||
|
SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)"
|
||||||
|
SOURCE_MTIME="${SOURCE_MTIME:-0}"
|
||||||
|
APK_MTIME=0
|
||||||
|
if [[ -n "$APK_PATH" && -f "$APK_PATH" ]]; then
|
||||||
|
APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)"
|
||||||
|
fi
|
||||||
|
APK_STALE=0
|
||||||
|
if (( SOURCE_MTIME > APK_MTIME )); then
|
||||||
|
APK_STALE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$APK_PATH" || "${FORCE_EXPORT:-0}" == "1" || "$APK_STALE" == "1" ]]; then
|
||||||
|
if [[ "$APK_STALE" == "1" && "${FORCE_EXPORT:-0}" != "1" ]]; then
|
||||||
|
echo "APK desatualizado em relação ao código fonte. Tentando gerar novo APK..."
|
||||||
|
fi
|
||||||
|
echo "Gerando APK (skip export)..."
|
||||||
|
if ! run_skip_export; then
|
||||||
|
echo "Aviso: skip export falhou. Tentando usar último APK gerado..."
|
||||||
|
if [[ -d "$PROJECT_DIR/Android" ]]; then
|
||||||
|
echo "Tentando fallback via Gradle (:app:assembleDebug)..."
|
||||||
|
(
|
||||||
|
cd "$PROJECT_DIR/Android"
|
||||||
|
ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT" gradle :app:assembleDebug --no-daemon
|
||||||
|
) || echo "Aviso: fallback Gradle também falhou."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
APK_PATH="$(find_apk_path)"
|
||||||
|
|
||||||
|
if [[ -z "$APK_PATH" ]]; then
|
||||||
|
if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then
|
||||||
|
echo "Aviso: APK não encontrado. Tentando abrir app já instalado: $APP_ID"
|
||||||
|
"$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1
|
||||||
|
echo "App aberto sem reinstalar (fallback)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Erro: APK não encontrado."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$REQUIRE_FRESH_APK" == "1" ]]; then
|
||||||
|
APK_MTIME="$(stat -f '%m' "$APK_PATH" 2>/dev/null || echo 0)"
|
||||||
|
SOURCE_MTIME="$(find "$PROJECT_DIR/Sources" -type f -name '*.swift' -exec stat -f '%m' {} \; 2>/dev/null | sort -nr | head -n1 || echo 0)"
|
||||||
|
if (( SOURCE_MTIME > APK_MTIME )); then
|
||||||
|
if [[ "$ALLOW_INSTALLED_FALLBACK" == "1" ]]; then
|
||||||
|
echo "Aviso: APK continua desatualizado (build novo não gerado)."
|
||||||
|
echo "Abrindo app já instalado para não bloquear o fluxo."
|
||||||
|
"$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1
|
||||||
|
echo "App aberto sem reinstalar (fallback)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Erro: APK continua desatualizado (build novo não gerado)."
|
||||||
|
echo "Não vou abrir versão antiga para evitar teste enganoso."
|
||||||
|
echo "Dica: rode com REQUIRE_FRESH_APK=0 para abrir mesmo assim."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Instalando APK: $APK_PATH"
|
||||||
|
"$ADB_BIN" -s "$TARGET_SERIAL" install -r "$APK_PATH"
|
||||||
|
|
||||||
|
LAUNCHABLE="$("$ADB_BIN" -s "$TARGET_SERIAL" shell cmd package resolve-activity --brief "$APP_ID" 2>/dev/null | tr -d '\r' | tail -n1)"
|
||||||
|
if [[ "$LAUNCHABLE" == */* ]]; then
|
||||||
|
echo "Abrindo app: $LAUNCHABLE"
|
||||||
|
"$ADB_BIN" -s "$TARGET_SERIAL" shell am start -n "$LAUNCHABLE"
|
||||||
|
else
|
||||||
|
echo "Abrindo app (fallback monkey): $APP_ID"
|
||||||
|
"$ADB_BIN" -s "$TARGET_SERIAL" shell monkey -p "$APP_ID" -c android.intent.category.LAUNCHER 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "App instalado e aberto no Android."
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
1. `[🟩]` Tela Login (MVP).
|
1. `[🟩]` Tela Login (MVP).
|
||||||
2. `[🟩]` Tela Cadastro (MVP UI).
|
2. `[🟩]` Tela Cadastro (MVP UI).
|
||||||
3. `[🟨]` Tela OTP (8 digitos).
|
3. `[🟨]` Tela OTP (8 digitos).
|
||||||
- `[🟩]` Feito: fluxo OTP integrado no login por email.
|
- `[🟩]` Feito: fluxo OTP integrado no login por email e telefone.
|
||||||
- `[🟥]` Pendente: UX final de OTP em 8 digitos conforme referencia final.
|
- `[🟥]` Pendente: UX final de OTP em 8 digitos conforme referencia final.
|
||||||
4. `[🟨]` Fluxo pos-login.
|
4. `[🟨]` Fluxo pos-login.
|
||||||
- `[🟩]` Feito: login valida token e entra no fluxo principal.
|
- `[🟩]` Feito: login valida token e entra no fluxo principal.
|
||||||
|
|||||||
Reference in New Issue
Block a user