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"?>
<!-- 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">
<!-- example permissions for using device 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_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- permissions needed for using the internet or an embedded WebKit browser -->
<uses-permission android:name="android.permission.INTERNET" />

View File

@@ -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<String>, 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 {

View File

@@ -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)
}
}
}

View File

@@ -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",

View File

@@ -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<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 {
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<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 {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL

View File

@@ -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<T: Decodable>: Decodable {
let error: Bool
let code: String?
@@ -16,18 +27,30 @@ final class ApiService {
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
func requestOtp(email: String) async throws -> ApiEnvelope<EmptyResult> {
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<LoginResult> {
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<LoginResult> = try await client.send(req)
let response: ApiEnvelope<LoginResult> = 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<CustomerProfile> {
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)
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}
}