diff --git a/API_Mobile_App.md b/API_Mobile_App.md index 1c4b175..133ea5c 100644 --- a/API_Mobile_App.md +++ b/API_Mobile_App.md @@ -65,15 +65,18 @@ Used when interacting with a specific store (Catalog, Checkout). ### 2. Login (Get Token) **POST** `/api/customer/login` -**Body:** +**Body (email + phoneNumber are mandatory):** ```json { "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.`. + **Response:** ```json diff --git a/pedi-foods/Android/app/src/main/kotlin/Main.kt b/pedi-foods/Android/app/src/main/kotlin/Main.kt index 4064592..517e668 100644 --- a/pedi-foods/Android/app/src/main/kotlin/Main.kt +++ b/pedi-foods/Android/app/src/main/kotlin/Main.kt @@ -149,7 +149,7 @@ open class MainActivity: AppCompatActivity { ActivityCompat.requestPermissions( 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 ) } diff --git a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj index 2431fa2..a366a6c 100644 --- a/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/pedi-foods/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; }; 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; }; 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; }; + B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -47,6 +48,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */, 491F27822DA55B72004926EE /* PediFoods in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -115,6 +117,7 @@ name = "PediFoods App"; packageProductDependencies = ( 491F27812DA55B72004926EE /* PediFoods */, + B13CABF12F36437300469FD6 /* LCEssentials */, ); productName = App; productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */; @@ -143,6 +146,7 @@ ); mainGroup = 49F90C1F2A52156200F06D93; packageReferences = ( + B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */, ); productRefGroup = 496BDBEC2B89A47800C09264 /* Products */; projectDirPath = ""; @@ -282,11 +286,27 @@ }; /* 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 */ 491F27812DA55B72004926EE /* PediFoods */ = { isa = XCSwiftPackageProductDependency; productName = PediFoods; }; + B13CABF12F36437300469FD6 /* LCEssentials */ = { + isa = XCSwiftPackageProductDependency; + package = B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */; + productName = LCEssentials; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 49F90C202A52156200F06D93 /* Project object */; diff --git a/pedi-foods/Package.swift b/pedi-foods/Package.swift index 678e73e..3d1ebe1 100644 --- a/pedi-foods/Package.swift +++ b/pedi-foods/Package.swift @@ -11,11 +11,14 @@ let package = Package( ], dependencies: [ .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: [ .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")]), + .testTarget(name: "PediFoodsTests", dependencies: ["PediFoods"]), ] ) diff --git a/pedi-foods/Skip.env b/pedi-foods/Skip.env index 97f1b74..0ea893e 100644 --- a/pedi-foods/Skip.env +++ b/pedi-foods/Skip.env @@ -21,3 +21,6 @@ ANDROID_PACKAGE_NAME = pedi.foods // If your Android appId is different from the iOS Bundle Identifer, specify it here // ANDROID_APPLICATION_ID = com.br.pedifoods.app + +// Atomenta endpoint +ATOMENTA_API_URL = "https://atomenta.com.br" diff --git a/pedi-foods/Sources/PediFoods/Components/Inputs.swift b/pedi-foods/Sources/PediFoods/Components/Inputs.swift index 50cf530..9039e4e 100644 --- a/pedi-foods/Sources/PediFoods/Components/Inputs.swift +++ b/pedi-foods/Sources/PediFoods/Components/Inputs.swift @@ -9,8 +9,7 @@ struct SearchField: View { Image(systemName: "magnifyingglass") .foregroundStyle(AppColors.secondary) TextField(placeholder, text: $text) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .appNoAutoCap() } .padding(.horizontal, 16) .frame(height: 52) diff --git a/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift b/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift new file mode 100644 index 0000000..249048c --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Components/SnackbarOverlay.swift @@ -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 + } +} diff --git a/pedi-foods/Sources/PediFoods/Components/TextFieldCompat.swift b/pedi-foods/Sources/PediFoods/Components/TextFieldCompat.swift new file mode 100644 index 0000000..225346f --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Components/TextFieldCompat.swift @@ -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 + } +} diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index f6185d2..790ef4c 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -5,20 +5,29 @@ struct ContentView: View { @State var selectedTab: MainTab = .home private let tokenStore: TokenStore = DefaultTokenStore() @State var appState = AppState() +#if os(Android) + @State var snackbarCenter = SnackbarCenter.shared +#else + @StateObject var snackbarCenter = SnackbarCenter.shared +#endif var body: some View { - Group { - switch root { - case .auth: - AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) - case .main: - MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + ZStack(alignment: .top) { + Group { + switch root { + case .auth: + AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + case .main: + MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + } } + + SnackbarOverlay(center: snackbarCenter) } .sheet(item: $appState.activeModal) { modal in switch modal { case .addressPicker: - AddressPickerModalView() + AddressPickerModalView(appState: $appState, selectedTab: $selectedTab) case .filters: FiltersModalView() } @@ -27,15 +36,40 @@ struct ContentView: View { } struct AddressPickerModalView: View { + @Binding var appState: AppState + @Binding var selectedTab: MainTab + @Environment(\.dismiss) var dismiss + var body: some View { NavigationStack { 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") .font(AppTypography.heading2) Text("Fluxo de endereco sera implementado na etapa de checkout/perfil.") .font(AppTypography.body) .multilineTextAlignment(.center) .foregroundStyle(AppColors.textMuted) + + Button("Ir para Perfil > Endereços") { + selectedTab = .profile + dismiss() + } + .font(AppTypography.heading3) + .foregroundStyle(AppColors.primary) + .padding(.top, 8) } .padding(24) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 8dd4596..4e1cb27 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -72,6 +72,10 @@ } } }, + "Adicionar novo endereço" : { + "comment" : "A button label that translates to \"Add new address\" in English.", + "isCommentAutoGenerated" : true + }, "Ajuda" : { "comment" : "A link to the help section of the app.", "isCommentAutoGenerated" : true @@ -114,6 +118,10 @@ "comment" : "A welcome message displayed in the login view.", "isCommentAutoGenerated" : true }, + "Buscando estabelecimentos próximos..." : { + "comment" : "A message indicating that the app is searching for nearby stores.", + "isCommentAutoGenerated" : true + }, "Cancel" : { "comment" : "Button title indicating that the operation should be cancelled", "extractionState" : "stale", @@ -152,6 +160,10 @@ "comment" : "A section header that lists available food categories.", "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..." : { "comment" : "A placeholder text describing the content of the privacy policy.", "isCommentAutoGenerated" : true @@ -256,10 +268,6 @@ "comment" : "A link that directs the user to the login screen.", "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." : { "comment" : "A description below the login fields, explaining that a verification code will be sent via SMS or email to confirm access.", "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?" : { "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 @@ -403,6 +419,10 @@ } } }, + "Meus Endereços" : { + "comment" : "A label displayed at the top of the view.", + "isCommentAutoGenerated" : true + }, "Name" : { "comment" : "Placeholder title for the Name field in a form", "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?" : { + }, + "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" : { "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.", "isCommentAutoGenerated" : true }, - "OTP" : { - "comment" : "The title of a view that asks for a one-time password (OTP).", + "para %@" : { + "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 }, "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.", "isCommentAutoGenerated" : true }, + "PRINCIPAL" : { + "comment" : "A label indicating that a particular address is the user's primary address.", + "isCommentAutoGenerated" : true + }, "Privacidade" : { "comment" : "The title of the privacy policy screen.", "isCommentAutoGenerated" : true @@ -536,6 +572,10 @@ "comment" : "A button label that says \"Receive Code\".", "isCommentAutoGenerated" : true }, + "Reenviar código" : { + "comment" : "A button that allows a user to request a new OTP code.", + "isCommentAutoGenerated" : true + }, "Save" : { "comment" : "Button title indicating that the current contents should be saved", "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" : { "comment" : "A link to the app's \"Terms of Use\".", "isCommentAutoGenerated" : true @@ -668,6 +712,10 @@ } } }, + "Verificação" : { + "comment" : "A title for the OTP verification screen.", + "isCommentAutoGenerated" : true + }, "Version %@ (%@)" : { "comment" : "Settings label showing the current version of the app", "extractionState" : "stale", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index 5175cdc..52ddaf9 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -2,6 +2,9 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif +#if canImport(LCEssentials) && os(iOS) +import LCEssentials +#endif enum NetworkError: Error, LocalizedError { case invalidURL @@ -36,7 +39,7 @@ enum NetworkError: Error, LocalizedError { } } -struct ApiRequest { +struct ApiRequest: Sendable { let path: String let method: String let module: ApiModule @@ -71,18 +74,117 @@ final class ApiClient { } func send(_ 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(_ 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(_ request: ApiRequest) async throws -> T { let url = try buildURL(path: request.path, query: request.queryItems) var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method 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) { - urlRequest.setValue(token, forHTTPHeaderField: "Atomenta-Token") - } - if request.requiresAuth, let jwt = tokenStore.jwt { - urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + for (key, value) in buildHeaders(for: request) { + urlRequest.setValue(value, forHTTPHeaderField: key) } var attempt = 1 @@ -105,7 +207,7 @@ final class ApiClient { throw NetworkError.invalidResponse } - private func perform(_ request: URLRequest, as type: T.Type) async throws -> T { + func perform(_ request: URLRequest, as type: T.Type) async throws -> T { let (data, response): (Data, URLResponse) do { (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 { case .rateLimited, .transportError: 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 { return UInt64(retryAfter) * 1_000_000_000 } @@ -156,7 +273,7 @@ final class ApiClient { 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.self, from: data) { return envelope.message } @@ -172,7 +289,29 @@ final class ApiClient { 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 { throw NetworkError.invalidURL } diff --git a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift index dd1e874..1e62dfa 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiConfig.swift @@ -1,6 +1,6 @@ import Foundation -enum ApiModule { +enum ApiModule: Sendable { case app case customer case store diff --git a/pedi-foods/Sources/PediFoods/Services/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 458b91d..f5765c5 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -41,14 +41,28 @@ final class ApiService { // MARK: - Auth - func requestOtp(email: String) async throws -> ApiEnvelope { - let body = try JSONEncoder().encode(["email": email]) + func registerCustomer(name: String, email: String, phoneNumber: String, birthDate: String? = nil) async throws -> ApiEnvelope { + 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 { + let body = try JSONEncoder().encode(["email": email, "phoneNumber": phoneNumber]) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) return try await send(req) } - func validateOtp(email: String, otp: String) async throws -> ApiEnvelope { - let body = try JSONEncoder().encode(["email": email, "otp": otp]) + func validateOtp(email: String, phoneNumber: String, otp: String) async throws -> ApiEnvelope { + 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 response: ApiEnvelope = try await send(req) if let token = response.result?.token { @@ -84,6 +98,12 @@ final class ApiService { struct EmptyResult: Decodable {} +struct RegistrationResult: Decodable { + let id: String? + let name: String? + let email: String? +} + struct LoginResult: Decodable { let token: String let customer: CustomerProfile? @@ -94,6 +114,43 @@ struct CustomerProfile: Decodable { let name: String let email: 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 { diff --git a/pedi-foods/Sources/PediFoods/Services/LocationService.swift b/pedi-foods/Sources/PediFoods/Services/LocationService.swift index 25b2da1..11cd212 100644 --- a/pedi-foods/Sources/PediFoods/Services/LocationService.swift +++ b/pedi-foods/Sources/PediFoods/Services/LocationService.swift @@ -4,8 +4,10 @@ import Foundation import CoreLocation #endif +@MainActor final class LocationService: NSObject { typealias LocationResult = Result<(Double, Double), LocationError> + static let shared = LocationService() #if os(iOS) enum LocationError: Error { @@ -35,13 +37,8 @@ final class LocationService: NSObject { func requestLocation(_ completion: @escaping (LocationResult) -> Void) { #if os(iOS) - guard CLLocationManager.locationServicesEnabled() else { - completion(.failure(.servicesDisabled)) - return - } self.completion = completion - manager.requestWhenInUseAuthorization() - manager.requestLocation() + handleAuthorizationStatus(manager.authorizationStatus) #else let defaults = UserDefaults.standard if defaults.bool(forKey: "location_permission_denied") { @@ -59,16 +56,55 @@ final class LocationService: NSObject { completion(.success((lat, lng))) #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) -extension LocationService: CLLocationManagerDelegate { +extension LocationService: @preconcurrency CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - let status = manager.authorizationStatus - if status == .denied || status == .restricted { - completion?(.failure(.denied)) - completion = nil - } + handleAuthorizationStatus(manager.authorizationStatus) } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { @@ -81,5 +117,20 @@ extension LocationService: CLLocationManagerDelegate { completion?(.failure(.unavailable)) 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 diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index 2f353de..6865be2 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -30,9 +30,10 @@ struct ProfileState { struct AddressState { var selectedId: String? = nil - var display: String = "Rua das Flores, 123" + var display: String = "Defina seu endereco" var latitude: Double? = nil var longitude: Double? = nil + var onboardingMessage: String? = nil } struct FavoritesState { diff --git a/pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift b/pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift new file mode 100644 index 0000000..093ed8e --- /dev/null +++ b/pedi-foods/Sources/PediFoods/State/SnackbarCenter.swift @@ -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? + + 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)? +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift index 8ec83cb..f599486 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift @@ -3,6 +3,7 @@ import SwiftUI enum Route: Hashable { case terms, policy case registration, loginEmail + case otp(email: String, phoneNumber: String) } struct AuthFlowView: View { @@ -10,7 +11,7 @@ struct AuthFlowView: View { @Binding var selectedTab: MainTab let tokenStore: TokenStore @Binding var appState: AppState - + @State var path: [Route] = [] var body: some View { @@ -27,385 +28,11 @@ struct AuthFlowView: View { case .registration: RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, navigate: { route in path.append(route) }) 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) - } -} - diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift new file mode 100644 index 0000000..6a73e39 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFormatters.swift @@ -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." +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift new file mode 100644 index 0000000..d875f39 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LegalViews.swift @@ -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 + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift new file mode 100644 index 0000000..f48022f --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginEmailView.swift @@ -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) + ) + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift new file mode 100644 index 0000000..f7be7e1 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/LoginView.swift @@ -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() + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift new file mode 100644 index 0000000..488ac3d --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/OtpView.swift @@ -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? + @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 + } + } + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift new file mode 100644 index 0000000..58abc04 --- /dev/null +++ b/pedi-foods/Sources/PediFoods/Views/Auth/RegistrationView.swift @@ -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) + } + } + } + } +} diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index 9e967cd..13c78c3 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -9,7 +9,9 @@ struct HomeView: View { @State var selectedCategory = "Stores" @State var scrollOffset: CGFloat = 0 @State var hasRequestedLocation = false - let locationService = LocationService() + @State var isLoadingStores = false + @State var storesError: String? = nil + @State var stores: [StoreSummary] = [] private let categories: [CategoryModel] = [ .init(title: "Stores", systemIcon: "storefront"), @@ -19,52 +21,6 @@ struct HomeView: View { .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] = [ .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")]) @@ -95,10 +51,6 @@ struct HomeView: View { .padding(.top, headerPadding + 16) .padding(.bottom, 24) } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetKey.self) { value in - scrollOffset = min(0, value) - } #endif header(collapseProgress: collapseProgress, height: headerHeight) @@ -110,14 +62,8 @@ struct HomeView: View { .onAppear { if hasRequestedLocation == false { hasRequestedLocation = true - locationService.requestLocation { result in - switch result { - case .success(let coordinate): - appState.address.latitude = coordinate.0 - appState.address.longitude = coordinate.1 - case .failure: - appState.address.display = "Defina seu endereco" - } + Task { + await bootstrapStoresFlow() } } } @@ -132,7 +78,7 @@ struct HomeView: View { section(title: "Featured") { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { - ForEach(featuredStores) { store in + ForEach(featuredStoresCards) { store in FeaturedStoreCard(store: store) .frame(width: 190) } @@ -154,12 +100,41 @@ struct HomeView: View { } section(title: "Near you") { - VStack(spacing: 16) { - ForEach(nearbyStores) { store in - FeaturedStoreCard(store: store) + if isLoadingStores { + HStack { + 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?") .font(AppTypography.heading1) .foregroundStyle(AppColors.textInverse) - .fixedSize(horizontal: false, vertical: true) .opacity(titleOpacity) .offset(y: collapseProgress * -20) @@ -294,6 +268,120 @@ struct HomeView: View { .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 { @@ -332,8 +420,7 @@ struct SearchBar: View { Image(systemName: "magnifyingglass") .foregroundStyle(AppColors.textMuted) TextField(placeholder, text: $text) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() + .appNoAutoCap() Spacer() Button(action: onFilterTap) { Image(systemName: "slider.horizontal.3") diff --git a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift index 1e3e408..9979e64 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift @@ -5,6 +5,8 @@ struct ProfileView: View { @Binding var selectedTab: MainTab let tokenStore: TokenStore @Binding var appState: AppState + @State var openAddressesOnboarding = false + @State var onboardingMessage: String? = nil var body: some View { VStack(spacing: 20) { @@ -16,7 +18,7 @@ struct ProfileView: View { } NavigationLink("Enderecos") { - Text("Enderecos") + AddressesView(message: nil, appState: $appState) } NavigationLink("Ajuda") { @@ -26,6 +28,13 @@ struct ProfileView: View { .font(AppTypography.body) .foregroundStyle(AppColors.textPrimary) + NavigationLink(isActive: $openAddressesOnboarding) { + AddressesView(message: onboardingMessage, appState: $appState) + } label: { + EmptyView() + } + .hidden() + Spacer() SecondaryButton(title: "Sair") { @@ -39,6 +48,14 @@ struct ProfileView: View { .padding(.top, 24) .frame(maxWidth: .infinity, maxHeight: .infinity) .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 { @@ -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 { var body: some View { VStack(spacing: 16) { diff --git a/pedi-foods/Tests/PediFoodsTests/SmokeTests.swift b/pedi-foods/Tests/PediFoodsTests/SmokeTests.swift new file mode 100644 index 0000000..a7e04db --- /dev/null +++ b/pedi-foods/Tests/PediFoodsTests/SmokeTests.swift @@ -0,0 +1,7 @@ +import Testing +@testable import PediFoods + +@Test("Smoke") +func smoke() { + #expect(Bool(true)) +} diff --git a/pedi-foods/scripts/android-run.sh b/pedi-foods/scripts/android-run.sh new file mode 100755 index 0000000..d221672 --- /dev/null +++ b/pedi-foods/scripts/android-run.sh @@ -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." diff --git a/pedifoods_app_plan.md b/pedifoods_app_plan.md index 3a3dd0c..538e3b2 100644 --- a/pedifoods_app_plan.md +++ b/pedifoods_app_plan.md @@ -38,7 +38,7 @@ 1. `[🟩]` Tela Login (MVP). 2. `[🟩]` Tela Cadastro (MVP UI). 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. 4. `[🟨]` Fluxo pos-login. - `[🟩]` Feito: login valida token e entra no fluxo principal.