Login and Home
This commit is contained in:
399
pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift
Normal file
399
pedi-foods/Sources/PediFoods/Views/Main/HomeView.swift
Normal 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
|
||||
Reference in New Issue
Block a user