Login and Home

This commit is contained in:
Daniel Arantes Loverde
2026-02-04 16:55:29 -03:00
parent 78daaf1927
commit 33fc111459
90 changed files with 1707 additions and 303 deletions

View File

@@ -0,0 +1,399 @@
import SwiftUI
#if os(iOS)
import UIKit
#endif
struct HomeView: View {
@State var searchText = ""
@State var selectedCategory = "Stores"
@State var scrollOffset: CGFloat = 0
private let categories: [CategoryModel] = [
.init(title: "Stores", systemIcon: "storefront"),
.init(title: "Asian", systemIcon: "fork.knife"),
.init(title: "Breakfast", systemIcon: "sun.max"),
.init(title: "Pizza", systemIcon: "takeoutbag.and.cup.and.straw"),
.init(title: "Dessert", systemIcon: "cup.and.saucer")
]
private let featuredStores: [FeaturedStoreCardModel] = [
.init(
name: "Burger Kingdom",
rating: 4.9,
reviews: "1.2k",
distance: "1.2 km",
category: "Fast Food",
promoText: "10% OFF",
isFavorite: false,
iconName: "takeoutbag.and.cup.and.straw"
),
.init(
name: "Sushi House",
rating: 4.8,
reviews: "840",
distance: "2.2 km",
category: "Japanese",
promoText: nil,
isFavorite: true,
iconName: "fork.knife"
)
]
private let nearbyStores: [FeaturedStoreCardModel] = [
.init(
name: "Pizza Prime",
rating: 4.7,
reviews: "510",
distance: "1.8 km",
category: "Pizza",
promoText: nil,
isFavorite: false,
iconName: "takeoutbag.and.cup.and.straw"
),
.init(
name: "Aoyama",
rating: 4.9,
reviews: "2.4k",
distance: "3.1 km",
category: "Japanese",
promoText: nil,
isFavorite: true,
iconName: "fork.knife"
)
]
private let specials: [SpecialOfferCardModel] = [
.init(title: "Get 50% OFF", subtitle: "For your first order", colors: [Color(hex: "#1E6B43"), Color(hex: "#58A56C")]),
.init(title: "Enjoy spicy day", subtitle: "Delivery in 20 min", colors: [Color(hex: "#F04B3E"), Color(hex: "#FF8C42")])
]
private let headerExpandedHeight: CGFloat = 260
private let headerCollapsedHeight: CGFloat = 120
private let contentTopPadding: CGFloat = 296
var body: some View {
let collapseProgress = clamp(value: -scrollOffset / (headerExpandedHeight - headerCollapsedHeight), lower: 0, upper: 1)
let headerHeight = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
let headerPadding = headerExpandedHeight - (headerExpandedHeight - headerCollapsedHeight) * collapseProgress
return ZStack(alignment: .top) {
#if os(iOS)
TrackableScrollView(onOffsetChange: { value in
scrollOffset = min(0, -value)
}) {
contentStack
.padding(.top, headerPadding + 16)
.padding(.bottom, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
#else
ScrollView {
contentStack
.padding(.top, headerPadding + 16)
.padding(.bottom, 24)
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { value in
scrollOffset = min(0, value)
}
#endif
header(collapseProgress: collapseProgress, height: headerHeight)
.frame(maxWidth: .infinity, alignment: .top)
}
.background(AppColors.backgroundLight)
.ignoresSafeArea(edges: .top)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
private var contentStack: some View {
VStack(spacing: 24) {
scrollOffsetMarker
categoriesSection
section(title: "Featured") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(featuredStores) { store in
FeaturedStoreCard(store: store)
.frame(width: 190)
}
}
.padding(.horizontal, 20)
}
}
section(title: "#SpecialForYou") {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(specials) { item in
SpecialOfferCard(model: item)
.frame(width: 260, height: 120)
}
}
.padding(.horizontal, 20)
}
}
section(title: "Near you") {
VStack(spacing: 16) {
ForEach(nearbyStores) { store in
FeaturedStoreCard(store: store)
}
}
.padding(.horizontal, 20)
}
}
}
private func header(collapseProgress: CGFloat, height: CGFloat) -> some View {
let titleOpacity = 1 - clamp(value: collapseProgress * 1.2, lower: 0, upper: 1)
let topRowOpacity = 1 - clamp(value: collapseProgress * 1.4, lower: 0, upper: 1)
return ZStack(alignment: .top) {
RoundedRectangle(cornerRadius: 32, style: .continuous)
.fill(AppColors.brandDark)
.frame(height: height)
.overlay(headerRings.opacity(1 - collapseProgress), alignment: .topTrailing)
VStack(alignment: .leading, spacing: 16) {
Spacer().frame(height: 20)
HStack(alignment: .center, spacing: 12) {
Circle()
.fill(AppColors.brandSoft)
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "person.fill")
.foregroundStyle(AppColors.brandDark)
)
VStack(alignment: .leading, spacing: 4) {
Text("DELIVERY LOCATION")
.font(AppTypography.overline)
.tracking(AppTypography.captionLetterSpacing)
.foregroundStyle(AppColors.brandSoft)
HStack(spacing: 6) {
Text("Canggu, Kuta Utara, Bali")
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textInverse)
Image(systemName: "chevron.down")
.font(.caption)
.foregroundStyle(AppColors.brandSoft)
}
}
Spacer()
Circle()
.fill(Color.white.opacity(0.18))
.frame(width: 40, height: 40)
.overlay(
Image(systemName: "bell")
.foregroundStyle(AppColors.textInverse)
)
}
.opacity(topRowOpacity)
.offset(y: collapseProgress * -12)
Text("O que vai querer pedir hoje?")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textInverse)
.fixedSize(horizontal: false, vertical: true)
.opacity(titleOpacity)
.offset(y: collapseProgress * -20)
SearchBar(placeholder: "Search menu, restaurant or craving", text: $searchText)
.offset(y: collapseProgress * -140)
}
.padding(.horizontal, 20)
.padding(.top, 18)
}
}
@ViewBuilder
private var scrollOffsetMarker: some View {
#if os(iOS)
EmptyView()
#else
ScrollOffsetReader()
.offset(y: -contentTopPadding)
#endif
}
private func section<Content: View>(title: String, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 16) {
Text(title)
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 20)
content()
}
}
private var categoriesSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Categories")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
.padding(.horizontal, 20)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(categories) { category in
CategoryChip(
title: category.title,
systemIcon: category.systemIcon,
isActive: category.title == selectedCategory
)
.onTapGesture {
selectedCategory = category.title
}
}
}
.padding(.horizontal, 20)
}
}
}
private var headerRings: some View {
ZStack {
Circle()
.stroke(Color.white.opacity(0.08), lineWidth: 1)
.frame(width: 180, height: 180)
.offset(x: 40, y: -10)
Circle()
.stroke(Color.white.opacity(0.08), lineWidth: 1)
.frame(width: 130, height: 130)
.offset(x: 70, y: 10)
}
}
}
struct CategoryModel: Identifiable {
let id = UUID()
let title: String
let systemIcon: String
}
struct CategoryChip: View {
let title: String
let systemIcon: String
let isActive: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: systemIcon)
.font(.caption)
Text(title)
.font(AppTypography.heading3)
}
.foregroundStyle(isActive ? AppColors.textInverse : AppColors.textPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isActive ? AppColors.brandDark : AppColors.surface)
.clipShape(Capsule())
}
}
struct SearchBar: View {
let placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.textMuted)
TextField(placeholder, text: $text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Spacer()
Image(systemName: "slider.horizontal.3")
.foregroundStyle(AppColors.textMuted)
}
.padding(.horizontal, 16)
.frame(height: 52)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
}
}
struct ScrollOffsetKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct ScrollOffsetReader: View {
var body: some View {
GeometryReader { proxy in
Color.clear
.preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .named("scroll")).minY)
}
.frame(height: 1)
}
}
func clamp(value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}
#if os(iOS)
@MainActor
struct TrackableScrollView<Content: View>: UIViewRepresentable {
let onOffsetChange: (CGFloat) -> Void
let content: Content
init(onOffsetChange: @escaping (CGFloat) -> Void, @ViewBuilder content: () -> Content) {
self.onOffsetChange = onOffsetChange
self.content = content()
}
func makeCoordinator() -> Coordinator {
Coordinator(onOffsetChange: onOffsetChange)
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.showsVerticalScrollIndicator = false
scrollView.alwaysBounceVertical = true
let host = UIHostingController(rootView: content)
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.backgroundColor = .clear
scrollView.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
host.view.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
host.view.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
host.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor)
])
context.coordinator.hostingController = host
scrollView.delegate = context.coordinator
return scrollView
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController?.rootView = content
}
final class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>?
let onOffsetChange: (CGFloat) -> Void
init(onOffsetChange: @escaping (CGFloat) -> Void) {
self.onOffsetChange = onOffsetChange
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
onOffsetChange(scrollView.contentOffset.y)
}
}
}
#endif