tabbar
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -7,11 +7,57 @@ struct ContentView: View {
|
|||||||
@State var appState = AppState()
|
@State var appState = AppState()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch root {
|
Group {
|
||||||
case .auth:
|
switch root {
|
||||||
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
case .auth:
|
||||||
case .main:
|
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState)
|
||||||
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
|
case .main:
|
||||||
|
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +191,19 @@ struct HomeView: View {
|
|||||||
.tracking(AppTypography.captionLetterSpacing)
|
.tracking(AppTypography.captionLetterSpacing)
|
||||||
.foregroundStyle(AppColors.brandSoft)
|
.foregroundStyle(AppColors.brandSoft)
|
||||||
|
|
||||||
HStack(spacing: 6) {
|
Button {
|
||||||
Text(appState.address.display)
|
appState.activeModal = .addressPicker
|
||||||
.font(AppTypography.heading3)
|
} label: {
|
||||||
.foregroundStyle(AppColors.textInverse)
|
HStack(spacing: 6) {
|
||||||
Image(systemName: "chevron.down")
|
Text(appState.address.display)
|
||||||
.font(.caption)
|
.font(AppTypography.heading3)
|
||||||
.foregroundStyle(AppColors.brandSoft)
|
.foregroundStyle(AppColors.textInverse)
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.caption)
|
||||||
|
.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,8 +335,10 @@ struct SearchBar: View {
|
|||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "slider.horizontal.3")
|
Button(action: onFilterTap) {
|
||||||
.foregroundStyle(AppColors.textMuted)
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.foregroundStyle(AppColors.textMuted)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.frame(height: 52)
|
.frame(height: 52)
|
||||||
|
|||||||
@@ -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) {
|
||||||
NavigationStack {
|
Group {
|
||||||
HomeView(appState: $appState)
|
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 {
|
customTabBar
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user