This commit is contained in:
Daniel Arantes Loverde
2026-02-06 09:51:53 -03:00
parent 379bb35cfa
commit 92c2a67736
13 changed files with 574 additions and 138 deletions

View File

@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- This AndroidManifest.xml template was generated by Skip --> <!-- This AndroidManifest.xml template was generated by Skip -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- example permissions for using device location --> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> -->
<!-- permissions needed for using the internet or an embedded WebKit browser --> <!-- permissions needed for using the internet or an embedded WebKit browser -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />

View File

@@ -8,6 +8,9 @@ import skip.ui.*
import android.Manifest import android.Manifest
import android.app.Application import android.app.Application
import android.graphics.Color as AndroidColor 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.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.SystemBarStyle import androidx.activity.SystemBarStyle
@@ -26,6 +29,7 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
internal val logger: SkipLogger = SkipLogger(subsystem = "pedi.foods", category = "PediFoods") 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. /// AndroidAppMain is initial `androidx.appcompat.app.AppCompatActivity`, and must match `activity android:name` in the AndroidMainfest.xml file.
open class MainActivity: AppCompatActivity { 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() { constructor() {
} }
@@ -68,17 +77,7 @@ open class MainActivity: AppCompatActivity {
} }
AppDelegate.shared.onLaunch() AppDelegate.shared.onLaunch()
requestLocationPermissionsIfNeeded()
// 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)
} }
override fun onStart() { override fun onStart() {
@@ -127,6 +126,65 @@ open class MainActivity: AppCompatActivity {
override fun onRequestPermissionsResult(requestCode: Int, permissions: kotlin.Array<String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: kotlin.Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults) super.onRequestPermissionsResult(requestCode, permissions, grantResults)
logger.info("onRequestPermissionsResult: ${requestCode}") 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 { companion object {

View File

@@ -7,6 +7,7 @@ struct ContentView: View {
@State var appState = AppState() @State var appState = AppState()
var body: some View { var body: some View {
Group {
switch root { switch root {
case .auth: case .auth:
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
@@ -14,4 +15,49 @@ struct ContentView: View {
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState) 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)
}
}
} }

View File

@@ -257,7 +257,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Enviado para %@" : { "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 "isCommentAutoGenerated" : true
}, },
"Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : { "Enviaremos um código de verificação por SMS \nou E-mail para confirmar seu acesso." : {
@@ -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)!" : { "Hello [%@](https://skip.tools)!" : {
"comment" : "Welcome tab contents", "comment" : "Welcome tab contents",
"extractionState" : "stale", "extractionState" : "stale",
@@ -463,7 +475,7 @@
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"OTP" : { "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 "isCommentAutoGenerated" : true
}, },
"Pedidos" : { "Pedidos" : {
@@ -554,6 +566,10 @@
} }
} }
}, },
"Selecionar endereco" : {
"comment" : "A title for the address picker modal view.",
"isCommentAutoGenerated" : true
},
"Settings" : { "Settings" : {
"comment" : "Tab bar item title for the Settings tab", "comment" : "Tab bar item title for the Settings tab",
"extractionState" : "stale", "extractionState" : "stale",

View File

@@ -7,8 +7,10 @@ enum NetworkError: Error, LocalizedError {
case invalidURL case invalidURL
case invalidResponse case invalidResponse
case httpError(Int, String?) case httpError(Int, String?)
case decodeError case unauthorized(String?)
case rateLimited case decodeError(String?)
case rateLimited(Int?)
case transportError(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
@@ -16,8 +18,20 @@ enum NetworkError: Error, LocalizedError {
case .invalidResponse: return "Resposta invalida do servidor" case .invalidResponse: return "Resposta invalida do servidor"
case .httpError(let code, let message): case .httpError(let code, let message):
return message ?? "Erro HTTP (\(code))" return message ?? "Erro HTTP (\(code))"
case .decodeError: return "Erro ao interpretar dados" case .unauthorized(let message):
case .rateLimited: return "Muitas requisicoes. Tente novamente." 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 { final class ApiClient {
private let session: URLSession private let session: URLSession
private let tokenStore: TokenStore private let tokenStore: TokenStore
private let maxAttempts = 3
private let baseBackoffNanoseconds: UInt64 = 300_000_000
init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) { init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) {
self.session = session self.session = session
@@ -69,27 +85,93 @@ final class ApiClient {
urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") 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<T: Decodable>(_ 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 { guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse throw NetworkError.invalidResponse
} }
if http.statusCode == 429 { 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) { if !(200...299).contains(http.statusCode) {
let message = String(data: data, encoding: .utf8) throw NetworkError.httpError(http.statusCode, serverMessage(from: data))
throw NetworkError.httpError(http.statusCode, message)
} }
do { do {
return try JSONDecoder().decode(T.self, from: data) return try JSONDecoder().decode(type, from: data)
} catch { } 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<EmptyResult>.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 { private func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else { guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL throw NetworkError.invalidURL

View File

@@ -1,5 +1,16 @@
import Foundation 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<T: Decodable>: Decodable { struct ApiEnvelope<T: Decodable>: Decodable {
let error: Bool let error: Bool
let code: String? let code: String?
@@ -16,18 +27,30 @@ final class ApiService {
self.tokenStore = tokenStore self.tokenStore = tokenStore
} }
private func send<T: Decodable>(_ 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 // MARK: - Auth
func requestOtp(email: String) async throws -> ApiEnvelope<EmptyResult> { func requestOtp(email: String) async throws -> ApiEnvelope<EmptyResult> {
let body = try JSONEncoder().encode(["email": email]) let body = try JSONEncoder().encode(["email": email])
let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body) let req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
return try await client.send(req) return try await send(req)
} }
func validateOtp(email: String, otp: String) async throws -> ApiEnvelope<LoginResult> { func validateOtp(email: String, otp: String) async throws -> ApiEnvelope<LoginResult> {
let body = try JSONEncoder().encode(["email": email, "otp": otp]) 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 req = ApiRequest(path: "/api/customer/login", method: "POST", module: .customer, requiresAuth: false, body: body)
let response: ApiEnvelope<LoginResult> = try await client.send(req) let response: ApiEnvelope<LoginResult> = try await send(req)
if let token = response.result?.token { if let token = response.result?.token {
tokenStore.jwt = token tokenStore.jwt = token
} }
@@ -36,7 +59,7 @@ final class ApiService {
func profile() async throws -> ApiEnvelope<CustomerProfile> { func profile() async throws -> ApiEnvelope<CustomerProfile> {
let req = ApiRequest(path: "/api/customer/profile", method: "GET", module: .customer, requiresAuth: true) 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 // MARK: - Stores
@@ -53,7 +76,7 @@ final class ApiService {
items.append(URLQueryItem(name: "search", value: search)) items.append(URLQueryItem(name: "search", value: search))
} }
let req = ApiRequest(path: "/api/app/stores", method: "GET", module: .app, requiresAuth: true, queryItems: items) 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)
} }
} }

View File

@@ -5,9 +5,24 @@ import CoreLocation
#endif #endif
final class LocationService: NSObject { 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) #if os(iOS)
private let manager = CLLocationManager() private let manager = CLLocationManager()
private var completion: ((Double, Double) -> Void)? private var completion: ((LocationResult) -> Void)?
#endif #endif
override init() { override init() {
@@ -18,28 +33,52 @@ final class LocationService: NSObject {
#endif #endif
} }
func requestLocation(_ completion: @escaping (Double, Double) -> Void) { func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
#if os(iOS) #if os(iOS)
guard CLLocationManager.locationServicesEnabled() else {
completion(.failure(.servicesDisabled))
return
}
self.completion = completion self.completion = completion
manager.requestWhenInUseAuthorization() manager.requestWhenInUseAuthorization()
manager.requestLocation() manager.requestLocation()
#else #else
// Android: implement later with platform-specific bridge let defaults = UserDefaults.standard
_ = completion 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 #endif
} }
} }
#if os(iOS) #if os(iOS)
extension LocationService: CLLocationManagerDelegate { 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]) { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.first else { return } guard let location = locations.first else { return }
completion?(location.coordinate.latitude, location.coordinate.longitude) completion?(.success((location.coordinate.latitude, location.coordinate.longitude)))
completion = nil completion = nil
} }
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
// Silence for now; UI can handle missing location. completion?(.failure(.unavailable))
completion = nil completion = nil
} }
} }

View File

@@ -1,4 +1,7 @@
import Foundation import Foundation
#if os(iOS)
import Security
#endif
protocol TokenStore: AnyObject { protocol TokenStore: AnyObject {
var jwt: String? { get set } var jwt: String? { get set }
@@ -10,11 +13,79 @@ final class DefaultTokenStore: TokenStore {
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
var jwt: String? { var jwt: String? {
get { defaults.string(forKey: key) } get {
set { defaults.set(newValue, forKey: key) } #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() { func clear() {
#if os(iOS)
deleteKeychainValue(for: key)
#endif
defaults.removeObject(forKey: key) 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
} }

View File

@@ -6,6 +6,14 @@ struct AppState {
var cart = CartState() var cart = CartState()
var address = AddressState() var address = AddressState()
var favorites = FavoritesState() var favorites = FavoritesState()
var activeModal: AppModal? = nil
}
enum AppModal: String, Identifiable {
case addressPicker
case filters
var id: String { rawValue }
} }
struct SessionState { struct SessionState {

View File

@@ -226,7 +226,7 @@ struct RegistrationView: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text("Já tem uma conta?") 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") { NavigationLink("Entrar") {
LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) LoginEmailView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
} }

View File

@@ -110,9 +110,14 @@ struct HomeView: View {
.onAppear { .onAppear {
if hasRequestedLocation == false { if hasRequestedLocation == false {
hasRequestedLocation = true hasRequestedLocation = true
locationService.requestLocation { lat, lng in locationService.requestLocation { result in
appState.address.latitude = lat switch result {
appState.address.longitude = lng case .success(let coordinate):
appState.address.latitude = coordinate.0
appState.address.longitude = coordinate.1
case .failure:
appState.address.display = "Defina seu endereco"
}
} }
} }
} }
@@ -186,6 +191,9 @@ struct HomeView: View {
.tracking(AppTypography.captionLetterSpacing) .tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(AppColors.brandSoft) .foregroundStyle(AppColors.brandSoft)
Button {
appState.activeModal = .addressPicker
} label: {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(appState.address.display) Text(appState.address.display)
.font(AppTypography.heading3) .font(AppTypography.heading3)
@@ -195,6 +203,8 @@ struct HomeView: View {
.foregroundStyle(AppColors.brandSoft) .foregroundStyle(AppColors.brandSoft)
} }
} }
.buttonStyle(.plain)
}
Spacer() Spacer()
@@ -216,7 +226,9 @@ struct HomeView: View {
.opacity(titleOpacity) .opacity(titleOpacity)
.offset(y: collapseProgress * -20) .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) .offset(y: collapseProgress * -140)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
@@ -313,6 +325,7 @@ struct CategoryChip: View {
struct SearchBar: View { struct SearchBar: View {
let placeholder: String let placeholder: String
@Binding var text: String @Binding var text: String
var onFilterTap: () -> Void = {}
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -322,9 +335,11 @@ struct SearchBar: View {
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
Spacer() Spacer()
Button(action: onFilterTap) {
Image(systemName: "slider.horizontal.3") Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted) .foregroundStyle(AppColors.textMuted)
} }
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.frame(height: 52) .frame(height: 52)
.background(AppColors.surface) .background(AppColors.surface)

View File

@@ -7,25 +7,66 @@ struct MainTabView: View {
@Binding var appState: AppState @Binding var appState: AppState
var body: some View { var body: some View {
TabView(selection: $selectedTab) { ZStack(alignment: .bottom) {
Group {
switch selectedTab {
case .home:
NavigationStack { NavigationStack {
HomeView(appState: $appState) HomeView(appState: $appState)
} }
.tabItem { Label("Home", systemImage: "house.fill") } case .cart:
.tag(MainTab.home)
NavigationStack { NavigationStack {
CartView() CartView()
} }
.tabItem { Label("Carrinho", systemImage: "cart.fill") } case .profile:
.tag(MainTab.cart)
NavigationStack { NavigationStack {
ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState) ProfileView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
} }
.tabItem { Label("Perfil", systemImage: "person.fill") }
.tag(MainTab.profile)
} }
.tint(AppColors.primary) }
customTabBar
}
}
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)
} }
} }

View File

@@ -1,90 +1,128 @@
# Plano de implementacao do app PediFoods (Skip + SwiftUI) # 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 ## Premissas de design e navegacao
- A tabbar e o centro de navegacao. Toda navegacao que nao for modal/over-context acontece dentro da tabbar. - 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. - 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. - 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. - Tipografia base: Plus Jakarta Sans.
## Etapa 1 — Fundacao tecnica (obrigatoria antes das telas) ## Etapa 1 — Fundacao tecnica
1. Criar o projeto Skip (Fuse) e estrutura base de pastas. - `[🟩]` Etapa geral
2. Configurar tokens do design system e tema (cores, tipografia, espacamentos, raios, sombras). 1. `[🟩]` Projeto Skip e estrutura base.
3. Implementar o service base de API: 2. `[🟩]` Design system (cores/tipo/layout).
- Camada HTTP 3. `[🟩]` Service base de API.
- Headers padrao - `[🟩]` 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).
- Tokens por modulo (customer, store, resource) 4. `[🟩]` Coordinator de navegacao.
- Tratamento de erros e rate limit - `[🟩]` Feito: fluxo `auth` -> `main`, tabbar com Home/Cart/Orders/Profile.
- Persistencia segura de JWT - `[🟩]` Feito: base de modais over-context (address picker e filtros) via `sheet` centralizado.
4. Criar um coordinator de navegacao: 5. `[🟩]` State global (MVP).
- Fluxo de autenticacao (login/cadastro/OTP) fora da tabbar 6. `[🟩]` Permissions e GPS.
- Fluxo principal com tabbar (Home, Cart, Orders, Profile) - `[🟩]` Feito: permissao/obter localizacao no iOS via `CoreLocation`, fallback de UX quando localizacao nao e obtida.
- Modais over-context (address, filtros, etc) - `[🟩]` Feito: permissao de localizacao no Android (manifest + runtime request), cache de ultima localizacao valida e fallback para indisponivel/negado.
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 2 — Autenticacao (fora da tabbar) ## Etapa 2 — Autenticacao (fora da tabbar)
1. Tela Login - `[🟨]` Etapa geral
2. Tela Cadastro 1. `[🟩]` Tela Login (MVP).
3. Tela OTP (8 digitos) 2. `[🟩]` Tela Cadastro (MVP UI).
4. Fluxo pos-login: 3. `[🟨]` Tela OTP (8 digitos).
- Buscar perfil - `[🟩]` Feito: fluxo OTP integrado no login por email.
- Abrir Home com tabbar ativa - `[🟥]` 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) ## Etapa 3 — Home e listagem de lojas (Tab Home)
1. Home com header, busca e categorias - `[🟨]` Etapa geral
2. Listagem de lojas (API /api/app/stores com lat/lng) 1. `[🟩]` Home com header, busca e categorias (MVP visual).
3. Filtros e busca 2. `[🟨]` Listagem de lojas via API `/api/app/stores`.
4. Favoritos - `[🟩]` 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 ## Etapa 4 — Store Detail e Catalogo
1. Tela de detalhes da loja - `[🟥]` Etapa geral
2. Catalogo por categorias 1. `[🟥]` Tela de detalhes da loja.
3. Produtos com addons 2. `[🟥]` Catalogo por categorias.
4. Favoritos de loja 3. `[🟥]` Produtos com addons.
4. `[🟥]` Favoritos de loja.
## Etapa 5 — Produto e carrinho ## Etapa 5 — Produto e carrinho
1. Tela de produto (detalhes + adicionais) - `[🟥]` Etapa geral
2. Carrinho (Tab Cart) 1. `[🟥]` Tela de produto (detalhes + adicionais).
3. Atualizacao de quantidade e subtotal 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 ## Etapa 6 — Checkout e pagamento
1. Tela de checkout - `[🟥]` Etapa geral
2. Endereco de entrega 1. `[🟥]` Tela de checkout.
3. Metodos de pagamento (PIX, credito, debito, dinheiro) 2. `[🟥]` Endereco de entrega.
4. Criacao de pedido 3. `[🟥]` Metodos de pagamento (PIX/credito/debito/dinheiro).
5. Fluxo de PIX (payload e QR) 4. `[🟥]` Criacao de pedido.
5. `[🟥]` Fluxo de PIX (payload e QR).
## Etapa 7 — Pedidos e rastreio (Tab Orders) ## Etapa 7 — Pedidos e rastreio
1. Lista de pedidos - `[🟥]` Etapa geral
2. Detalhe do pedido 1. `[🟨]` Lista de pedidos.
3. Tracking de status - `[🟨]` 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) ## Etapa 8 — Perfil e enderecos
1. Perfil do usuario - `[🟥]` Etapa geral
2. Lista de enderecos 1. `[🟨]` Perfil do usuario.
3. Criar/editar endereco - `[🟩]` 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 ## Etapa 9 — Mapa e GPS
1. Mapa iOS via MapKit - `[🟥]` Etapa geral
2. Mapa Android via ComposeView (Google Maps) 1. `[🟥]` Mapa iOS via MapKit.
3. Pinos de loja e localizacao atual 2. `[🟥]` Mapa Android via ComposeView (Google Maps).
4. Ajustes de permissao e fallback sem GPS 3. `[🟥]` Pinos de loja e localizacao atual.
4. `[🟨]` Ajustes de permissao e fallback sem GPS.
## Etapa 10 — Polimento e release ## Etapa 10 — Polimento e release
1. Dark mode - `[🟥]` Etapa geral
2. Estados vazios e erros 1. `[🟨]` Dark mode (presente em partes de auth).
3. Performance e cache 2. `[🟨]` Estados vazios e erros.
4. Build iOS e Android 3. `[🟥]` Performance e cache.
5. Export APK/AAB 4. `[🟨]` Build iOS e Android (estrutura pronta, falta validacao final de fluxo completo).
5. `[🟥]` Export APK/AAB.
## Etapa 11 — Ajustes de UI (referencias) ## Etapa 11 — Ajustes de UI (referencias)
1. Login (light/dark) - `[🟨]` Etapa geral
2. Cadastro 1. `[🟨]` Login (light/dark).
3. OTP 2. `[🟨]` Cadastro.
4. Carrinho 3. `[🟨]` OTP.
5. Checkout 4. `[🟥]` Carrinho.
6. Perfil 5. `[🟥]` Checkout.
7. Pedidos 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.