diff --git a/pedi-foods/Android/app/src/main/AndroidManifest.xml b/pedi-foods/Android/app/src/main/AndroidManifest.xml index c6c7049..2253f6f 100644 --- a/pedi-foods/Android/app/src/main/AndroidManifest.xml +++ b/pedi-foods/Android/app/src/main/AndroidManifest.xml @@ -1,9 +1,8 @@ - - - + + diff --git a/pedi-foods/Android/app/src/main/kotlin/Main.kt b/pedi-foods/Android/app/src/main/kotlin/Main.kt index c2648b9..4064592 100644 --- a/pedi-foods/Android/app/src/main/kotlin/Main.kt +++ b/pedi-foods/Android/app/src/main/kotlin/Main.kt @@ -8,6 +8,9 @@ import skip.ui.* import android.Manifest import android.app.Application import android.graphics.Color as AndroidColor +import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.SystemBarStyle @@ -26,6 +29,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalContext import androidx.compose.material3.MaterialTheme import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat internal val logger: SkipLogger = SkipLogger(subsystem = "pedi.foods", category = "PediFoods") @@ -50,6 +54,11 @@ open class AndroidAppMain: Application { /// AndroidAppMain is initial `androidx.appcompat.app.AppCompatActivity`, and must match `activity android:name` in the AndroidMainfest.xml file. open class MainActivity: AppCompatActivity { + private val locationRequestCode = 4201 + private val locationPermissionDeniedKey = "location_permission_denied" + private val locationLatitudeKey = "last_location_lat" + private val locationLongitudeKey = "last_location_lng" + constructor() { } @@ -68,17 +77,7 @@ open class MainActivity: AppCompatActivity { } AppDelegate.shared.onLaunch() - - // Example of requesting permissions on startup. - // These must match the permissions in the AndroidManifest.xml file. - //let permissions = listOf( - // Manifest.permission.ACCESS_COARSE_LOCATION, - // Manifest.permission.ACCESS_FINE_LOCATION - // Manifest.permission.CAMERA, - // Manifest.permission.WRITE_EXTERNAL_STORAGE, - //) - //let requestTag = 1 - //ActivityCompat.requestPermissions(self, permissions.toTypedArray(), requestTag) + requestLocationPermissionsIfNeeded() } override fun onStart() { @@ -127,6 +126,65 @@ open class MainActivity: AppCompatActivity { override fun onRequestPermissionsResult(requestCode: Int, permissions: kotlin.Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) logger.info("onRequestPermissionsResult: ${requestCode}") + if (requestCode == locationRequestCode) { + val granted = grantResults.isNotEmpty() && grantResults.all { it == PackageManager.PERMISSION_GRANTED } + val prefs = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + prefs.edit().putBoolean(locationPermissionDeniedKey, !granted).apply() + if (granted) { + cacheLastKnownLocation() + } + } + } + + private fun requestLocationPermissionsIfNeeded() { + val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + val granted = fine == PackageManager.PERMISSION_GRANTED || coarse == PackageManager.PERMISSION_GRANTED + if (granted) { + val prefs = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + prefs.edit().putBoolean(locationPermissionDeniedKey, false).apply() + cacheLastKnownLocation() + return + } + + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION), + locationRequestCode + ) + } + + private fun cacheLastKnownLocation() { + val manager = getSystemService(LOCATION_SERVICE) as? LocationManager ?: return + val fineGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + val coarseGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + if (!fineGranted && !coarseGranted) { + return + } + + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + var best: Location? = null + for (provider in providers) { + val candidate = try { + manager.getLastKnownLocation(provider) + } catch (_: SecurityException) { + null + } catch (_: Exception) { + null + } + if (candidate != null && (best == null || candidate.accuracy < best!!.accuracy)) { + best = candidate + } + } + + if (best != null) { + val prefs = getSharedPreferences("${packageName}_preferences", MODE_PRIVATE) + prefs.edit() + .putString(locationLatitudeKey, best.latitude.toString()) + .putString(locationLongitudeKey, best.longitude.toString()) + .putBoolean(locationPermissionDeniedKey, false) + .apply() + } } companion object { diff --git a/pedi-foods/Sources/PediFoods/ContentView.swift b/pedi-foods/Sources/PediFoods/ContentView.swift index c7574f5..f6185d2 100644 --- a/pedi-foods/Sources/PediFoods/ContentView.swift +++ b/pedi-foods/Sources/PediFoods/ContentView.swift @@ -7,11 +7,57 @@ struct ContentView: View { @State var appState = AppState() var body: some View { - switch root { - case .auth: - AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) - case .main: - MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) + 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) + } + } + .sheet(item: $appState.activeModal) { modal in + switch modal { + case .addressPicker: + AddressPickerModalView() + case .filters: + FiltersModalView() + } + } + } +} + +struct AddressPickerModalView: View { + var body: some View { + NavigationStack { + VStack(spacing: 12) { + 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) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) + } + } +} + +struct FiltersModalView: View { + var body: some View { + NavigationStack { + VStack(spacing: 12) { + Text("Filtros") + .font(AppTypography.heading2) + Text("Filtros de busca serao ligados na integracao real da Home com API.") + .font(AppTypography.body) + .multilineTextAlignment(.center) + .foregroundStyle(AppColors.textMuted) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppColors.backgroundLight) } } } diff --git a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings index 4f7f85c..8dd4596 100644 --- a/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/pedi-foods/Sources/PediFoods/Resources/Localizable.xcstrings @@ -257,7 +257,7 @@ "isCommentAutoGenerated" : true }, "Enviado para %@" : { - "comment" : "A label below the \"OTP\" title that indicates the email to which the OTP was sent. The argument is the email address to which the OTP was sent.", + "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." : { @@ -294,6 +294,18 @@ } } }, + "Filtros" : { + "comment" : "A label for the filter options in the modal.", + "isCommentAutoGenerated" : true + }, + "Filtros de busca serao ligados na integracao real da Home com API." : { + "comment" : "A description of the filters feature that will be added to the home screen.", + "isCommentAutoGenerated" : true + }, + "Fluxo de endereco sera implementado na etapa de checkout/perfil." : { + "comment" : "A message explaining that the address selection feature will be implemented in the checkout and profile screens.", + "isCommentAutoGenerated" : true + }, "Hello [%@](https://skip.tools)!" : { "comment" : "Welcome tab contents", "extractionState" : "stale", @@ -463,7 +475,7 @@ "isCommentAutoGenerated" : true }, "OTP" : { - "comment" : "The title of a view that asks the user to enter an OTP code.", + "comment" : "The title of a view that asks for a one-time password (OTP).", "isCommentAutoGenerated" : true }, "Pedidos" : { @@ -554,6 +566,10 @@ } } }, + "Selecionar endereco" : { + "comment" : "A title for the address picker modal view.", + "isCommentAutoGenerated" : true + }, "Settings" : { "comment" : "Tab bar item title for the Settings tab", "extractionState" : "stale", diff --git a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift index 1e29cf7..5175cdc 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiClient.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiClient.swift @@ -7,8 +7,10 @@ enum NetworkError: Error, LocalizedError { case invalidURL case invalidResponse case httpError(Int, String?) - case decodeError - case rateLimited + case unauthorized(String?) + case decodeError(String?) + case rateLimited(Int?) + case transportError(String) var errorDescription: String? { switch self { @@ -16,8 +18,20 @@ enum NetworkError: Error, LocalizedError { case .invalidResponse: return "Resposta invalida do servidor" case .httpError(let code, let message): return message ?? "Erro HTTP (\(code))" - case .decodeError: return "Erro ao interpretar dados" - case .rateLimited: return "Muitas requisicoes. Tente novamente." + case .unauthorized(let message): + return message ?? "Sessao expirada. Faca login novamente." + case .decodeError(let payload): + if let payload, payload.isEmpty == false { + return "Erro ao interpretar dados: \(payload)" + } + return "Erro ao interpretar dados" + case .rateLimited(let retryAfter): + if let retryAfter { + return "Muitas requisicoes. Tente novamente em \(retryAfter)s." + } + return "Muitas requisicoes. Tente novamente." + case .transportError(let message): + return "Erro de rede: \(message)" } } } @@ -48,6 +62,8 @@ struct ApiRequest { final class ApiClient { private let session: URLSession private let tokenStore: TokenStore + private let maxAttempts = 3 + private let baseBackoffNanoseconds: UInt64 = 300_000_000 init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) { self.session = session @@ -69,27 +85,93 @@ final class ApiClient { urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: urlRequest) + var attempt = 1 + while attempt <= maxAttempts { + do { + return try await perform(urlRequest, as: T.self) + } catch let error as NetworkError { + guard shouldRetry(error), attempt < maxAttempts else { + throw error + } + try await Task.sleep(nanoseconds: backoff(for: attempt, error: error)) + attempt += 1 + } catch { + let wrapped = NetworkError.transportError(error.localizedDescription) + guard attempt < maxAttempts else { throw wrapped } + try await Task.sleep(nanoseconds: backoff(for: attempt, error: wrapped)) + attempt += 1 + } + } + throw NetworkError.invalidResponse + } + + private 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) + } catch { + throw NetworkError.transportError(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { throw NetworkError.invalidResponse } if http.statusCode == 429 { - throw NetworkError.rateLimited + let retryAfter = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "") + throw NetworkError.rateLimited(retryAfter) + } + + if http.statusCode == 401 || http.statusCode == 403 { + throw NetworkError.unauthorized(serverMessage(from: data)) } if !(200...299).contains(http.statusCode) { - let message = String(data: data, encoding: .utf8) - throw NetworkError.httpError(http.statusCode, message) + throw NetworkError.httpError(http.statusCode, serverMessage(from: data)) } do { - return try JSONDecoder().decode(T.self, from: data) + return try JSONDecoder().decode(type, from: data) } catch { - throw NetworkError.decodeError + throw NetworkError.decodeError(String(data: data, encoding: .utf8)) } } + private func shouldRetry(_ error: NetworkError) -> Bool { + switch error { + case .rateLimited, .transportError: + return true + case .httpError(let statusCode, _): + return statusCode >= 500 + case .invalidURL, .invalidResponse, .decodeError, .unauthorized: + return false + } + } + + private func backoff(for attempt: Int, error: NetworkError) -> UInt64 { + if case .rateLimited(let retryAfter) = error, let retryAfter { + return UInt64(retryAfter) * 1_000_000_000 + } + let multiplier = UInt64(max(1, attempt)) + return min(baseBackoffNanoseconds * multiplier, 2_000_000_000) + } + + private func serverMessage(from data: Data) -> String? { + if let envelope = try? JSONDecoder().decode(ApiEnvelope.self, from: data) { + return envelope.message + } + + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let message = object["message"] as? String { + return message + } + if let code = object["code"] as? String { + return "Erro: \(code)" + } + } + return String(data: data, encoding: .utf8) + } + private 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/ApiService.swift b/pedi-foods/Sources/PediFoods/Services/ApiService.swift index 6cc3916..458b91d 100644 --- a/pedi-foods/Sources/PediFoods/Services/ApiService.swift +++ b/pedi-foods/Sources/PediFoods/Services/ApiService.swift @@ -1,5 +1,16 @@ import Foundation +enum ApiServiceError: Error, LocalizedError { + case sessionExpired(String?) + + var errorDescription: String? { + switch self { + case .sessionExpired(let message): + return message ?? "Sessao expirada. Faca login novamente." + } + } +} + struct ApiEnvelope: Decodable { let error: Bool let code: String? @@ -16,18 +27,30 @@ final class ApiService { self.tokenStore = tokenStore } + private func send(_ req: ApiRequest) async throws -> T { + do { + return try await client.send(req) + } catch let error as NetworkError { + if case .unauthorized(let message) = error { + tokenStore.clear() + throw ApiServiceError.sessionExpired(message) + } + throw error + } + } + // MARK: - Auth func requestOtp(email: String) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(["email": email]) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) - return try await client.send(req) + return try await send(req) } func validateOtp(email: String, otp: String) async throws -> ApiEnvelope { let body = try JSONEncoder().encode(["email": email, "otp": otp]) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) - let response: ApiEnvelope = try await client.send(req) + let response: ApiEnvelope = try await send(req) if let token = response.result?.token { tokenStore.jwt = token } @@ -36,7 +59,7 @@ final class ApiService { func profile() async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) - return try await client.send(req) + return try await send(req) } // MARK: - Stores @@ -53,7 +76,7 @@ final class ApiService { items.append(URLQueryItem(name: "search", value: search)) } let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) - return try await client.send(req) + return try await send(req) } } diff --git a/pedi-foods/Sources/PediFoods/Services/LocationService.swift b/pedi-foods/Sources/PediFoods/Services/LocationService.swift index 94af9f8..25b2da1 100644 --- a/pedi-foods/Sources/PediFoods/Services/LocationService.swift +++ b/pedi-foods/Sources/PediFoods/Services/LocationService.swift @@ -5,9 +5,24 @@ import CoreLocation #endif final class LocationService: NSObject { + typealias LocationResult = Result<(Double, Double), LocationError> + +#if os(iOS) + enum LocationError: Error { + case servicesDisabled + case denied + case unavailable + } +#else + enum LocationError: Error { + case denied + case unavailable + } +#endif + #if os(iOS) private let manager = CLLocationManager() - private var completion: ((Double, Double) -> Void)? + private var completion: ((LocationResult) -> Void)? #endif override init() { @@ -18,28 +33,52 @@ final class LocationService: NSObject { #endif } - func requestLocation(_ completion: @escaping (Double, Double) -> Void) { + func requestLocation(_ completion: @escaping (LocationResult) -> Void) { #if os(iOS) + guard CLLocationManager.locationServicesEnabled() else { + completion(.failure(.servicesDisabled)) + return + } self.completion = completion manager.requestWhenInUseAuthorization() manager.requestLocation() #else - // Android: implement later with platform-specific bridge - _ = completion + let defaults = UserDefaults.standard + if defaults.bool(forKey: "location_permission_denied") { + completion(.failure(.denied)) + return + } + + 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 { + completion(.failure(.unavailable)) + return + } + completion(.success((lat, lng))) #endif } } #if os(iOS) extension LocationService: CLLocationManagerDelegate { + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + let status = manager.authorizationStatus + if status == .denied || status == .restricted { + completion?(.failure(.denied)) + completion = nil + } + } + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.first else { return } - completion?(location.coordinate.latitude, location.coordinate.longitude) + completion?(.success((location.coordinate.latitude, location.coordinate.longitude))) completion = nil } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - // Silence for now; UI can handle missing location. + completion?(.failure(.unavailable)) completion = nil } } diff --git a/pedi-foods/Sources/PediFoods/Services/TokenStore.swift b/pedi-foods/Sources/PediFoods/Services/TokenStore.swift index 6760242..7813a31 100644 --- a/pedi-foods/Sources/PediFoods/Services/TokenStore.swift +++ b/pedi-foods/Sources/PediFoods/Services/TokenStore.swift @@ -1,4 +1,7 @@ import Foundation +#if os(iOS) +import Security +#endif protocol TokenStore: AnyObject { var jwt: String? { get set } @@ -10,11 +13,79 @@ final class DefaultTokenStore: TokenStore { private let defaults = UserDefaults.standard var jwt: String? { - get { defaults.string(forKey: key) } - set { defaults.set(newValue, forKey: key) } + get { +#if os(iOS) + if let keychainValue = loadKeychainValue(for: key) { + return keychainValue + } +#endif + return defaults.string(forKey: key) + } + set { +#if os(iOS) + if let newValue { + saveKeychainValue(newValue, for: key) + } else { + deleteKeychainValue(for: key) + } +#endif + defaults.set(newValue, forKey: key) + } } func clear() { +#if os(iOS) + deleteKeychainValue(for: key) +#endif defaults.removeObject(forKey: key) } + +#if os(iOS) + private var serviceName: String { "com.br.pedifoods.app.auth" } + + private func saveKeychainValue(_ value: String, for key: String) { + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + SecItemAdd(attributes as CFDictionary, nil) + } + + private func loadKeychainValue(for key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + return value + } + + private func deleteKeychainValue(for key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +#endif } diff --git a/pedi-foods/Sources/PediFoods/State/AppState.swift b/pedi-foods/Sources/PediFoods/State/AppState.swift index e76d477..2f353de 100644 --- a/pedi-foods/Sources/PediFoods/State/AppState.swift +++ b/pedi-foods/Sources/PediFoods/State/AppState.swift @@ -6,6 +6,14 @@ struct AppState { var cart = CartState() var address = AddressState() var favorites = FavoritesState() + var activeModal: AppModal? = nil +} + +enum AppModal: String, Identifiable { + case addressPicker + case filters + + var id: String { rawValue } } struct SessionState { diff --git a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift index b50ce23..8ec83cb 100644 --- a/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift @@ -226,7 +226,7 @@ struct RegistrationView: View { HStack(spacing: 6) { Text("JΓ‘ tem uma conta?") - .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.secondary) + .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.8) : AppColors.textPrimary) NavigationLink("Entrar") { LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) } diff --git a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift index e747ace..9e967cd 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift @@ -110,9 +110,14 @@ struct HomeView: View { .onAppear { if hasRequestedLocation == false { hasRequestedLocation = true - locationService.requestLocation { lat, lng in - appState.address.latitude = lat - appState.address.longitude = lng + 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" + } } } } @@ -186,14 +191,19 @@ struct HomeView: View { .tracking(AppTypography.captionLetterSpacing) .foregroundStyle(AppColors.brandSoft) - HStack(spacing: 6) { - Text(appState.address.display) - .font(AppTypography.heading3) - .foregroundStyle(AppColors.textInverse) - Image(systemName: "chevron.down") - .font(.caption) - .foregroundStyle(AppColors.brandSoft) + Button { + appState.activeModal = .addressPicker + } label: { + HStack(spacing: 6) { + Text(appState.address.display) + .font(AppTypography.heading3) + .foregroundStyle(AppColors.textInverse) + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(AppColors.brandSoft) + } } + .buttonStyle(.plain) } Spacer() @@ -216,7 +226,9 @@ struct HomeView: View { .opacity(titleOpacity) .offset(y: collapseProgress * -20) - SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) + SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText) { + appState.activeModal = .filters + } .offset(y: collapseProgress * -140) } .padding(.horizontal, 20) @@ -313,6 +325,7 @@ struct CategoryChip: View { struct SearchBar: View { let placeholder: String @Binding var text: String + var onFilterTap: () -> Void = {} var body: some View { HStack(spacing: 12) { @@ -322,8 +335,10 @@ struct SearchBar: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() Spacer() - Image(systemName: "slider.horizontal.3") - .foregroundStyle(AppColors.textMuted) + Button(action: onFilterTap) { + Image(systemName: "slider.horizontal.3") + .foregroundStyle(AppColors.textMuted) + } } .padding(.horizontal, 16) .frame(height: 52) diff --git a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift index 5cbab15..300209c 100644 --- a/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift +++ b/pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift @@ -7,25 +7,66 @@ struct MainTabView: View { @Binding var appState: AppState var body: some View { - TabView(selection: $selectedTab) { - NavigationStack { - HomeView(appState: $appState) + ZStack(alignment: .bottom) { + Group { + switch selectedTab { + case .home: + NavigationStack { + HomeView(appState: $appState) + } + case .cart: + NavigationStack { + CartView() + } + case .profile: + NavigationStack { + ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) + } + } } - .tabItem { Label("Home", systemImage: "house.fill") } - .tag(MainTab.home) - NavigationStack { - CartView() - } - .tabItem { Label("Carrinho", systemImage: "cart.fill") } - .tag(MainTab.cart) - - NavigationStack { - ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) - } - .tabItem { Label("Perfil", systemImage: "person.fill") } - .tag(MainTab.profile) + customTabBar } - .tint(AppColors.primary) + } + + private var customTabBar: some View { + HStack(spacing: 12) { + tabBarButton(tab: .home, title: "Home", icon: "house.fill") + tabBarButton(tab: .cart, title: "Carrinho", icon: "cart.fill") + tabBarButton(tab: .profile, title: "Perfil", icon: "person.fill") + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(AppColors.surface.opacity(0.95)) + ) + .padding(.horizontal, 18) + .padding(.bottom, 10) + .shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y) + } + + private func tabBarButton(tab: MainTab, title: String, icon: String) -> some View { + let isActive = selectedTab == tab + return Button { + selectedTab = tab + } label: { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + if isActive { + Text(title) + .font(AppTypography.heading3) + } + } + .foregroundStyle(isActive ? AppColors.primary : AppColors.textPrimary) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .background( + Capsule() + .fill(isActive ? AppColors.brandSoft : Color.clear) + ) + } + .buttonStyle(.plain) } } diff --git a/pedifoods_app_plan.md b/pedifoods_app_plan.md index 5f903fe..3a3dd0c 100644 --- a/pedifoods_app_plan.md +++ b/pedifoods_app_plan.md @@ -1,90 +1,128 @@ # Plano de implementacao do app PediFoods (Skip + SwiftUI) +## Legenda +- `[πŸŸ₯]` Nao feito +- `[🟨]` Parcial +- `[🟩]` Completo + +## Estado atual (snapshot) +- `[🟩]` Base Skip + SwiftUI criada e compilando com fluxos `auth` e `main`. +- `[🟩]` Design system ativo com paleta, tipografia (Plus Jakarta Sans), raios e sombras. +- `[🟨]` Autenticacao com fluxo funcional de login por email/OTP (integracao inicial de API) e cadastro com navegacao pronta. +- `[🟨]` Home refeita com layout de referencia (header verde, categorias, cards, specials) e efeito de colapso em iOS. +- `[🟩]` Estado global (`AppState`) em uso para sessao, perfil, endereco, carrinho e favoritos. +- `[🟩]` API client/service com envelopes, retry/backoff, tratamento de erros/rate limit, politica de sessao expirada e persistencia segura de JWT no iOS. + ## Premissas de design e navegacao - A tabbar e o centro de navegacao. Toda navegacao que nao for modal/over-context acontece dentro da tabbar. - Login e cadastro ficam fora da tabbar. Ao logar/cadastrar, a Home abre ja com as tabs ativas. - O verde da imagem `home_tabs.png` e o primary. O verde fluorescente sera usado raramente e fica como cor terciaria. - Tipografia base: Plus Jakarta Sans. -## Etapa 1 β€” Fundacao tecnica (obrigatoria antes das telas) -1. Criar o projeto Skip (Fuse) e estrutura base de pastas. -2. Configurar tokens do design system e tema (cores, tipografia, espacamentos, raios, sombras). -3. Implementar o service base de API: - - Camada HTTP - - Headers padrao - - Tokens por modulo (customer, store, resource) - - Tratamento de erros e rate limit - - Persistencia segura de JWT -4. Criar um coordinator de navegacao: - - Fluxo de autenticacao (login/cadastro/OTP) fora da tabbar - - Fluxo principal com tabbar (Home, Cart, Orders, Profile) - - Modais over-context (address, filtros, etc) -5. Configurar state global (sessao, perfil, cart, endereco ativo, favoritos). -6. Integrar permissions e GPS: - - Solicitar permissao ao cadastrar endereΓ§o - - Obter localizacao atual ao cadastrar endereΓ§o - - Cache de ultima localizacao valida +## Etapa 1 β€” Fundacao tecnica +- `[🟩]` Etapa geral +1. `[🟩]` Projeto Skip e estrutura base. +2. `[🟩]` Design system (cores/tipo/layout). +3. `[🟩]` Service base de API. +- `[🟩]` Feito: camada HTTP, headers padrao, tokens por modulo, tratamento de HTTP e 429, retry/backoff para falhas transientes, decode de erro com payload, politica de sessao expirada (`401/403`), limpeza de token em expiracao, persistencia de JWT em Keychain (iOS). +4. `[🟩]` Coordinator de navegacao. +- `[🟩]` Feito: fluxo `auth` -> `main`, tabbar com Home/Cart/Orders/Profile. +- `[🟩]` Feito: base de modais over-context (address picker e filtros) via `sheet` centralizado. +5. `[🟩]` State global (MVP). +6. `[🟩]` Permissions e GPS. +- `[🟩]` Feito: permissao/obter localizacao no iOS via `CoreLocation`, fallback de UX quando localizacao nao e obtida. +- `[🟩]` Feito: permissao de localizacao no Android (manifest + runtime request), cache de ultima localizacao valida e fallback para indisponivel/negado. ## Etapa 2 β€” Autenticacao (fora da tabbar) -1. Tela Login -2. Tela Cadastro -3. Tela OTP (8 digitos) -4. Fluxo pos-login: - - Buscar perfil - - Abrir Home com tabbar ativa +- `[🟨]` Etapa geral +1. `[🟩]` Tela Login (MVP). +2. `[🟩]` Tela Cadastro (MVP UI). +3. `[🟨]` Tela OTP (8 digitos). +- `[🟩]` Feito: fluxo OTP integrado no login por email. +- `[πŸŸ₯]` Pendente: UX final de OTP em 8 digitos conforme referencia final. +4. `[🟨]` Fluxo pos-login. +- `[🟩]` Feito: login valida token e entra no fluxo principal. +- `[πŸŸ₯]` Pendente: buscar perfil completo e sincronizar estado antes da Home. ## Etapa 3 β€” Home e listagem de lojas (Tab Home) -1. Home com header, busca e categorias -2. Listagem de lojas (API /api/app/stores com lat/lng) -3. Filtros e busca -4. Favoritos +- `[🟨]` Etapa geral +1. `[🟩]` Home com header, busca e categorias (MVP visual). +2. `[🟨]` Listagem de lojas via API `/api/app/stores`. +- `[🟩]` Feito: metodo de service pronto. +- `[πŸŸ₯]` Pendente: bind real da Home com dados de API (atualmente usa mocks visuais). +3. `[🟨]` Filtros e busca. +- `[🟩]` Feito: UI base de busca/categorias e gatilho de modal de filtros. +- `[πŸŸ₯]` Pendente: filtros reais + query server-side. +4. `[🟨]` Favoritos. +- `[🟩]` Feito: estado e affordance visual no card. +- `[πŸŸ₯]` Pendente: persistencia/sync com backend. ## Etapa 4 β€” Store Detail e Catalogo -1. Tela de detalhes da loja -2. Catalogo por categorias -3. Produtos com addons -4. Favoritos de loja +- `[πŸŸ₯]` Etapa geral +1. `[πŸŸ₯]` Tela de detalhes da loja. +2. `[πŸŸ₯]` Catalogo por categorias. +3. `[πŸŸ₯]` Produtos com addons. +4. `[πŸŸ₯]` Favoritos de loja. ## Etapa 5 β€” Produto e carrinho -1. Tela de produto (detalhes + adicionais) -2. Carrinho (Tab Cart) -3. Atualizacao de quantidade e subtotal +- `[πŸŸ₯]` Etapa geral +1. `[πŸŸ₯]` Tela de produto (detalhes + adicionais). +2. `[🟨]` Carrinho (Tab Cart). +- `[🟩]` Feito: tab e tela placeholder. +- `[πŸŸ₯]` Pendente: itens reais, regra de negocio e estados. +3. `[πŸŸ₯]` Quantidade e subtotal. ## Etapa 6 β€” Checkout e pagamento -1. Tela de checkout -2. Endereco de entrega -3. Metodos de pagamento (PIX, credito, debito, dinheiro) -4. Criacao de pedido -5. Fluxo de PIX (payload e QR) +- `[πŸŸ₯]` Etapa geral +1. `[πŸŸ₯]` Tela de checkout. +2. `[πŸŸ₯]` Endereco de entrega. +3. `[πŸŸ₯]` Metodos de pagamento (PIX/credito/debito/dinheiro). +4. `[πŸŸ₯]` Criacao de pedido. +5. `[πŸŸ₯]` Fluxo de PIX (payload e QR). -## Etapa 7 β€” Pedidos e rastreio (Tab Orders) -1. Lista de pedidos -2. Detalhe do pedido -3. Tracking de status +## Etapa 7 β€” Pedidos e rastreio +- `[πŸŸ₯]` Etapa geral +1. `[🟨]` Lista de pedidos. +- `[🟨]` Feito: tab Orders criada + tela placeholder. +- `[πŸŸ₯]` Pendente: lista real, detalhe e tracking. +2. `[πŸŸ₯]` Detalhe do pedido. +3. `[πŸŸ₯]` Tracking de status. -## Etapa 8 β€” Perfil e enderecos (Tab Profile) -1. Perfil do usuario -2. Lista de enderecos -3. Criar/editar endereco +## Etapa 8 β€” Perfil e enderecos +- `[πŸŸ₯]` Etapa geral +1. `[🟨]` Perfil do usuario. +- `[🟩]` Feito: tela com dados do estado e logout. +- `[πŸŸ₯]` Pendente: edicao completa e dados vindos da API. +2. `[πŸŸ₯]` Lista de enderecos. +3. `[πŸŸ₯]` Criar/editar endereco. ## Etapa 9 β€” Mapa e GPS -1. Mapa iOS via MapKit -2. Mapa Android via ComposeView (Google Maps) -3. Pinos de loja e localizacao atual -4. Ajustes de permissao e fallback sem GPS +- `[πŸŸ₯]` Etapa geral +1. `[πŸŸ₯]` Mapa iOS via MapKit. +2. `[πŸŸ₯]` Mapa Android via ComposeView (Google Maps). +3. `[πŸŸ₯]` Pinos de loja e localizacao atual. +4. `[🟨]` Ajustes de permissao e fallback sem GPS. ## Etapa 10 β€” Polimento e release -1. Dark mode -2. Estados vazios e erros -3. Performance e cache -4. Build iOS e Android -5. Export APK/AAB +- `[πŸŸ₯]` Etapa geral +1. `[🟨]` Dark mode (presente em partes de auth). +2. `[🟨]` Estados vazios e erros. +3. `[πŸŸ₯]` Performance e cache. +4. `[🟨]` Build iOS e Android (estrutura pronta, falta validacao final de fluxo completo). +5. `[πŸŸ₯]` Export APK/AAB. ## Etapa 11 β€” Ajustes de UI (referencias) -1. Login (light/dark) -2. Cadastro -3. OTP -4. Carrinho -5. Checkout -6. Perfil -7. Pedidos +- `[🟨]` Etapa geral +1. `[🟨]` Login (light/dark). +2. `[🟨]` Cadastro. +3. `[🟨]` OTP. +4. `[πŸŸ₯]` Carrinho. +5. `[πŸŸ₯]` Checkout. +6. `[🟨]` Perfil. +7. `[🟨]` Pedidos. + +## Proximos focos recomendados +1. `[🟨]` Conectar Home com API real (`stores`, busca, categoria, favoritos). +2. `[🟨]` Fechar fluxo auth pos-login (perfil + hidratacao de estado). +3. `[πŸŸ₯]` Implementar carrinho real (itens, subtotal, edicao) para destravar checkout. +4. `[πŸŸ₯]` Implementar mapa e tracking de pedidos.