This commit is contained in:
Daniel Arantes Loverde
2026-07-07 15:11:31 -03:00
parent 8b9ebdcb44
commit e51c99973f
293 changed files with 457 additions and 4919 deletions

View File

@@ -0,0 +1,70 @@
import SwiftUI
struct PrimaryButton: View {
let title: String
var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
HStack {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
if let image {
image
}
}
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}
struct PrimaryButtonLabel: View {
let title: String
var fullWidth: Bool = true
var body: some View {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
.foregroundStyle(AppColors.textInverse)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.primary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.labelStyle(.titleOnly)
}
}
struct SecondaryButton: View {
let title: String
var fullWidth: Bool = true
var image: Image? = nil
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
HStack {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
if let image {
image
}
}
.foregroundStyle(AppColors.textPrimary)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.secondary)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,216 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
enum ImageFitMode {
/// Scale to cover the whole box, cropping whichever axis overflows.
/// Default matches historical behavior for every image in the app.
case fill
/// Scale so height always matches the box exactly; width follows the
/// source's aspect ratio and gets cropped/gapped on the sides. Opt-in
/// only used by the Store Detail cover header.
case heightFit
}
struct CachedRemoteImage<Placeholder: View>: View {
let imageURL: String?
let ttl: TimeInterval
let fitMode: ImageFitMode
let placeholder: Placeholder
#if canImport(UIKit) || canImport(AppKit)
@StateObject var loader = CachedRemoteImageLoader()
#endif
init(
imageURL: String?,
ttl: TimeInterval = AppCacheTTL.twoHours,
fitMode: ImageFitMode = .fill,
@ViewBuilder placeholder: () -> Placeholder
) {
self.imageURL = imageURL
self.ttl = ttl
self.fitMode = fitMode
self.placeholder = placeholder()
}
var body: some View {
GeometryReader { geometry in
Group {
#if canImport(UIKit)
if let uiImage = loader.uiImage {
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
} else {
placeholder
}
#elseif canImport(AppKit)
if let nsImage = loader.nsImage {
rendered(image: Image(nsImage: nsImage), pixelSize: nsImage.size, in: geometry.size)
} else {
placeholder
}
#else
if let imageURL,
let url = URL(string: imageURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
placeholder
}
}
} else {
placeholder
}
#endif
}
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
.onAppear {
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: imageURL, ttl: ttl)
#endif
}
.onChange(of: imageURL) { _, newValue in
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: newValue, ttl: ttl)
#endif
}
}
#if canImport(UIKit) || canImport(AppKit)
@ViewBuilder
private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View {
switch fitMode {
case .fill:
image
.resizable()
.scaledToFill()
case .heightFit:
let aspect = pixelSize.height > 0 ? pixelSize.width / pixelSize.height : 1
let renderWidth = containerSize.height * aspect
image
.resizable()
.frame(width: renderWidth, height: containerSize.height)
.frame(width: containerSize.width, height: containerSize.height)
}
}
#endif
}
#if canImport(UIKit) || canImport(AppKit)
@MainActor
final class CachedRemoteImageLoader: ObservableObject {
#if canImport(UIKit)
@Published var uiImage: UIImage?
#elseif canImport(AppKit)
@Published var nsImage: NSImage?
#endif
private var currentKey: String?
private var task: Task<Void, Never>?
deinit {
task?.cancel()
}
func load(imageURL: String?, ttl: TimeInterval) {
let normalized = Self.normalizeImageSource(imageURL)
let key = normalized ?? ""
guard currentKey != key else { return }
currentKey = key
task?.cancel()
#if canImport(UIKit)
uiImage = nil
#elseif canImport(AppKit)
nsImage = nil
#endif
guard let normalized, normalized.isEmpty == false else { return }
#if canImport(UIKit) || canImport(AppKit)
let dataCacheKey = Self.dataURLCacheKey(normalized)
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
#if canImport(UIKit)
uiImage = cachedDataImage
#elseif canImport(AppKit)
nsImage = cachedDataImage
#endif
return
}
if let image = Self.imageFromDataURL(normalized) {
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
#if canImport(UIKit)
uiImage = image
#elseif canImport(AppKit)
nsImage = image
#endif
return
}
#endif
guard let url = URL(string: normalized) else { return }
task = Task { [weak self] in
#if canImport(UIKit) || canImport(AppKit)
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
guard Task.isCancelled == false else { return }
await MainActor.run {
#if canImport(UIKit)
self?.uiImage = image
#elseif canImport(AppKit)
self?.nsImage = image
#endif
}
#endif
}
}
private static func normalizeImageSource(_ value: String?) -> String? {
ImageSourceResolver.resolve(value)
}
private static func dataURLCacheKey(_ source: String) -> String {
let head = String(source.prefix(48))
let tail = String(source.suffix(48))
return "data-image:\(source.count):\(head):\(tail)"
}
#if canImport(UIKit) || canImport(AppKit)
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
let lower = source.lowercased()
guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil }
let header = String(source[..<commaIndex]).lowercased()
guard header.contains(";base64") else { return nil }
let payloadStart = source.index(after: commaIndex)
let payload = String(source[payloadStart...])
.replacingOccurrences(of: "\\/", with: "/")
.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
.replacingOccurrences(of: " ", with: "")
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#else
return nil
#endif
}
#endif
}
#endif

View File

@@ -0,0 +1,43 @@
import SwiftUI
struct SearchField: View {
var placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.primary)
TextField(placeholder, text: $text)
.appNoAutoCap()
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(AppColors.backgroundLight)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.primary.opacity(0.15), lineWidth: 1)
)
}
}
struct PillButton: View {
let title: String
let isActive: Bool
var body: some View {
Text(title)
.font(AppTypography.caption)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.primary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isActive ? AppColors.primary : AppColors.backgroundLight)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(AppColors.primary.opacity(0.15), lineWidth: isActive ? 0 : 1)
)
}
}

View File

@@ -0,0 +1,38 @@
import SwiftUI
struct SnackbarOverlay: View {
@ObservedObject var center: SnackbarCenter
var body: some View {
VStack {
if let message = center.current {
HStack(spacing: 10) {
if let icon = message.iconSystemName, !icon.isEmpty {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
}
Text(message.title)
.font(AppTypography.heading3)
.multilineTextAlignment(.leading)
.lineLimit(3)
Spacer(minLength: 0)
}
.foregroundStyle(Color.white)
.padding(.top, 14)
.padding(.horizontal, 16)
.padding(.bottom, 16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(message.style.backgroundColor)
.appContentShape(Rectangle())
.onTapGesture {
center.handleTap()
}
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(999)
}
Spacer()
}
.animation(.spring(response: 0.3, dampingFraction: 0.9), value: center.current?.id)
.allowsHitTesting(center.current != nil)
}
}

View File

@@ -0,0 +1,173 @@
import SwiftUI
struct FeaturedStoreCard: View {
let store: FeaturedStoreCardModel
var onFavoriteToggle: (() -> Void)? = nil
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ZStack(alignment: .topLeading) {
mediaBlock
if let promo = store.promoText {
Text(promo)
.font(AppTypography.caption)
.foregroundStyle(AppColors.textInverse)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.red)
.clipShape(Capsule())
.padding(10)
}
HStack {
Spacer()
Button {
onFavoriteToggle?()
} label: {
Image(systemName: store.isFavorite ? "heart.fill" : "heart")
.foregroundStyle(store.isFavorite ? Color.red : AppColors.textMuted)
.padding(8)
.background(AppColors.surface)
.clipShape(Circle())
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
.buttonStyle(.borderless)
.accessibilityLabel(store.isFavorite ? "Remover loja dos favoritos" : "Adicionar loja aos favoritos")
.padding(10)
}
}
Text(store.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
if store.isOpen {
HStack(spacing: 6) {
Image(systemName: "star.fill")
.font(.caption)
.foregroundStyle(Color(hex: "#F5B335"))
Text(String(format: "%.1f", store.rating))
.font(.caption)
.foregroundStyle(AppColors.textPrimary)
Text("(\(store.reviews))")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
Text("·")
.font(.caption)
.foregroundStyle(AppColors.textMuted)
Text(store.distance)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
} else {
Text(store.statusLabel?.isEmpty == false ? (store.statusLabel ?? "Fechado") : "Fechado")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
}
Text(store.category)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
.saturation(store.isOpen ? 1 : 0)
.opacity(store.isOpen ? 1 : 0.9)
}
private var mediaBlock: some View {
ZStack {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.brandSoft)
mediaImage
}
.frame(height: 120)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
@ViewBuilder
private var mediaImage: some View {
AsyncStoreImage(imageURL: store.imageURL)
}
private var storeIconPlaceholder: some View {
ZStack {
Image("placeholder-product")
.resizable()
.scaledToFill()
.opacity(0.7)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
Circle()
.fill(AppColors.surface.opacity(0.92))
.frame(width: 64, height: 64)
.overlay(
Image(systemName: store.iconName)
.font(.title2)
.foregroundStyle(AppColors.primary)
)
}
}
}
struct FeaturedStoreCardModel: Identifiable {
let id: String
let name: String
let rating: Double
let reviews: String
let distance: String
let deliveryFee: Double?
let category: String
let promoText: String?
let isFavorite: Bool
let iconName: String
let imageURL: String?
let logoURL: String?
let coverURL: String?
let isOpen: Bool
let statusLabel: String?
}
struct SpecialOfferCard: View {
let model: SpecialOfferCardModel
var body: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous)
.fill(
LinearGradient(
colors: model.colors,
startPoint: .leading,
endPoint: .trailing
)
)
Circle()
.fill(Color.white.opacity(0.18))
.frame(width: 120, height: 120)
.offset(x: 140, y: 10)
VStack(alignment: .leading, spacing: 8) {
Text(model.title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textInverse)
Text(model.subtitle)
.font(AppTypography.body)
.foregroundStyle(AppColors.textInverse.opacity(0.85))
}
.padding(20)
}
.shadow(color: AppShadow.soft.color, radius: AppShadow.soft.radius, y: AppShadow.soft.y)
}
}
struct SpecialOfferCardModel: Identifiable {
let id: String
let title: String
let subtitle: String
let colors: [Color]
}

View File

@@ -0,0 +1,25 @@
import SwiftUI
extension View {
@ViewBuilder
func appNoAutoCap() -> some View {
#if os(iOS)
self
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#else
self
#endif
}
@ViewBuilder
func appOTPKeyboard() -> some View {
#if os(iOS)
self
.keyboardType(.numberPad)
.textContentType(.oneTimeCode)
#else
self
#endif
}
}