tabbar
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user