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

BIN
logo/icon_pix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

BIN
logo/pedifoods.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
logo/pedifoods_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -1,122 +1,116 @@
{
"images" : [
"images": [
{
"filename" : "AppIcon-20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
"idiom": "iphone",
"size": "20x20",
"scale": "2x",
"filename": "icon_iphone_20pt@2x.png"
},
{
"filename" : "AppIcon-20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
"idiom": "iphone",
"size": "20x20",
"scale": "3x",
"filename": "icon_iphone_20pt@3x.png"
},
{
"filename" : "AppIcon-29.png",
"idiom" : "iphone",
"scale" : "1x",
"size" : "29x29"
"idiom": "iphone",
"size": "29x29",
"scale": "2x",
"filename": "icon_iphone_29pt@2x.png"
},
{
"filename" : "AppIcon-29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
"idiom": "iphone",
"size": "29x29",
"scale": "3x",
"filename": "icon_iphone_29pt@3x.png"
},
{
"filename" : "AppIcon-29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
"idiom": "iphone",
"size": "40x40",
"scale": "2x",
"filename": "icon_iphone_40pt@2x.png"
},
{
"filename" : "AppIcon-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
"idiom": "iphone",
"size": "40x40",
"scale": "3x",
"filename": "icon_iphone_40pt@3x.png"
},
{
"filename" : "AppIcon-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
"idiom": "iphone",
"size": "60x60",
"scale": "2x",
"filename": "icon_iphone_60pt@2x.png"
},
{
"filename" : "AppIcon@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
"idiom": "iphone",
"size": "60x60",
"scale": "3x",
"filename": "icon_iphone_60pt@3x.png"
},
{
"filename" : "AppIcon@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
"idiom": "ipad",
"size": "20x20",
"scale": "1x",
"filename": "icon_ipad_20pt@1x.png"
},
{
"filename" : "AppIcon-20~ipad.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "20x20"
"idiom": "ipad",
"size": "20x20",
"scale": "2x",
"filename": "icon_ipad_20pt@2x.png"
},
{
"filename" : "AppIcon-20@2x~ipad.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "20x20"
"idiom": "ipad",
"size": "29x29",
"scale": "1x",
"filename": "icon_ipad_29pt@1x.png"
},
{
"filename" : "AppIcon-29~ipad.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "29x29"
"idiom": "ipad",
"size": "29x29",
"scale": "2x",
"filename": "icon_ipad_29pt@2x.png"
},
{
"filename" : "AppIcon-29@2x~ipad.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "29x29"
"idiom": "ipad",
"size": "40x40",
"scale": "1x",
"filename": "icon_ipad_40pt@1x.png"
},
{
"filename" : "AppIcon-40~ipad.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "40x40"
"idiom": "ipad",
"size": "40x40",
"scale": "2x",
"filename": "icon_ipad_40pt@2x.png"
},
{
"filename" : "AppIcon-40@2x~ipad.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "40x40"
"idiom": "ipad",
"size": "76x76",
"scale": "1x",
"filename": "icon_ipad_76pt@1x.png"
},
{
"filename" : "AppIcon~ipad.png",
"idiom" : "ipad",
"scale" : "1x",
"size" : "76x76"
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "icon_ipad_76pt@2x.png"
},
{
"filename" : "AppIcon@2x~ipad.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
"idiom": "ipad",
"size": "83.5x83.5",
"scale": "2x",
"filename": "icon_ipad_83.5pt@2x.png"
},
{
"filename" : "AppIcon-83.5@2x~ipad.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "AppIcon~ios-marketing.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
"idiom": "ios-marketing",
"size": "1024x1024",
"scale": "1x",
"filename": "icon_ios-marketing_1024pt@1x.png"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"version": 1,
"author": "xcode"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,52 @@
{
"images": [
{
"idiom": "universal",
"filename": "login_light.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
},
{
"idiom": "universal",
"filename": "login_dark.png",
"scale": "1x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "2x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "3x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -0,0 +1,52 @@
{
"images": [
{
"idiom": "universal",
"filename": "pedifoods.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
},
{
"idiom": "universal",
"filename": "pedifoods_dark.png",
"scale": "1x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "2x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "3x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,52 @@
{
"images": [
{
"idiom": "universal",
"filename": "pin_image_app.png",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x"
},
{
"idiom": "universal",
"scale": "3x"
},
{
"idiom": "universal",
"filename": "pin_image_app_dark.png",
"scale": "1x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "2x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
},
{
"idiom": "universal",
"scale": "3x",
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
]
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -183,7 +183,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = "/bin/sh -e";
shellScript = "if [ \"${SKIP_ZERO}\" != \"\" ]; then\n echo \"note: skipping skip due to SKIP_ZERO\"\n exit 0\nelif [ \"${ENABLE_PREVIEWS}\" = \"YES\" ]; then\n echo \"note: skipping skip due to ENABLE_PREVIEWS\"\n exit 0\nelif [ \"${ACTION}\" = \"install\" ]; then\n echo \"note: skipping skip due to archive install\"\n exit 0\nelif [ \"${SKIP_ACTION}\" = \"none\" ]; then\n echo \"note: skipping skip due to SKIP_ACTION none\"\n exit 0\nelse\n SKIP_ACTION=\"${SKIP_ACTION:-launch}\"\nfi\nPATH=${BUILD_ROOT}/Release:${BUILD_ROOT}/Debug:${BUILD_ROOT}/../../SourcePackages/artifacts/skip/skip/skip.artifactbundle/macos:${PATH}:${HOMEBREW_PREFIX:-/opt/homebrew}/bin\necho \"note: running gradle build with: $(which skip) gradle -p ${PWD}/../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\"\nskip gradle -p ../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\n";
shellScript = "if [ \"${SKIP_ZERO}\" != \"\" ]; then\n echo \"note: skipping skip due to SKIP_ZERO\"\n exit 0\nelif [ \"${ENABLE_PREVIEWS}\" = \"YES\" ]; then\n echo \"note: skipping skip due to ENABLE_PREVIEWS\"\n exit 0\nelif [ \"${ACTION}\" = \"install\" ]; then\n echo \"note: skipping skip due to archive install\"\n exit 0\nelif [ \"${PLATFORM_NAME}\" = \"iphonesimulator\" ]; then\n echo \"note: skipping skip gradle for iOS Simulator\"\n exit 0\nelif [ \"${SKIP_ACTION}\" = \"none\" ]; then\n echo \"note: skipping skip due to SKIP_ACTION none\"\n exit 0\nelse\n SKIP_ACTION=\"${SKIP_ACTION:-launch}\"\nfi\nPATH=${BUILD_ROOT}/Release:${BUILD_ROOT}/Debug:${BUILD_ROOT}/../../SourcePackages/artifacts/skip/skip/skip.artifactbundle/macos:${PATH}:${HOMEBREW_PREFIX:-/opt/homebrew}/bin\necho \"note: running gradle build with: $(which skip) gradle -p ${PWD}/../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\"\nskip gradle -p ../Android ${SKIP_ACTION:-launch}${CONFIGURATION:-Debug}\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -203,7 +203,9 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
};
@@ -213,7 +215,9 @@
isa = XCBuildConfiguration;
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
buildSettings = {
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
};
@@ -244,7 +248,7 @@
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf-with-dsym;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct PrimaryButton: View {
let title: String
var fullWidth: Bool = true
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
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))
}
.buttonStyle(.plain)
}
}
struct SecondaryButton: View {
let title: String
var fullWidth: Bool = true
let action: @MainActor @Sendable () -> Void
var body: some View {
Button(action: { action() }) {
Text(title)
.font(AppTypography.button)
.tracking(AppTypography.buttonLetterSpacing)
.foregroundStyle(AppColors.primary)
.frame(maxWidth: fullWidth ? .infinity : nil)
.frame(height: 56)
.background(AppColors.backgroundLight)
.overlay(
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.stroke(AppColors.primary.opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct SearchField: View {
var placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(AppColors.secondary)
TextField(placeholder, text: $text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
.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.secondary.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.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(isActive ? AppColors.primary : AppColors.backgroundLight)
.clipShape(Capsule())
.overlay(
Capsule()
.stroke(AppColors.secondary.opacity(0.15), lineWidth: isActive ? 0 : 1)
)
}
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
struct FeaturedStoreCard: View {
let store: FeaturedStoreCardModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ZStack(alignment: .topLeading) {
RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)
.fill(AppColors.brandSoft)
.frame(height: 120)
.overlay(
Circle()
.fill(AppColors.surface)
.frame(width: 64, height: 64)
.overlay(
Image(systemName: store.iconName)
.font(.title2)
.foregroundStyle(AppColors.primary)
)
)
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(action: {}) {
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)
}
.padding(10)
}
}
Text(store.name)
.font(AppTypography.heading3)
.foregroundStyle(AppColors.textPrimary)
.lineLimit(1)
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)
}
Text(store.category)
.font(.caption)
.foregroundStyle(AppColors.textMuted)
}
.padding(14)
.background(AppColors.surface)
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusXL, style: .continuous))
}
}
struct FeaturedStoreCardModel: Identifiable {
let id = UUID()
let name: String
let rating: Double
let reviews: String
let distance: String
let category: String
let promoText: String?
let isFavorite: Bool
let iconName: 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 = UUID()
let title: String
let subtitle: String
let colors: [Color]
}

View File

@@ -1,177 +1,16 @@
import SwiftUI
enum ContentTab: String, Hashable {
case welcome, home, settings
}
struct ContentView: View {
@AppStorage("tab") var tab = ContentTab.welcome
@AppStorage("name") var welcomeName = "Skipper"
@AppStorage("appearance") var appearance = ""
@State var viewModel = ViewModel()
@State var root: RootFlow = DefaultTokenStore().jwt == nil ? .auth : .main
@State var selectedTab: MainTab = .home
private let tokenStore: TokenStore = DefaultTokenStore()
var body: some View {
TabView(selection: $tab) {
NavigationStack {
WelcomeView(welcomeName: $welcomeName)
}
.tabItem { Label("Welcome", systemImage: "heart.fill") }
.tag(ContentTab.welcome)
NavigationStack {
ItemListView()
.navigationTitle(Text("\(viewModel.items.count) Items"))
}
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(ContentTab.home)
NavigationStack {
SettingsView(appearance: $appearance, welcomeName: $welcomeName)
.navigationTitle("Settings")
}
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
.tag(ContentTab.settings)
}
.environment(viewModel)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
}
}
struct WelcomeView : View {
@State var heartBeating = false
@Binding var welcomeName: String
var body: some View {
VStack(spacing: 0) {
Text("Hello [\(welcomeName)](https://skip.tools)!")
.padding()
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.scaleEffect(heartBeating ? 1.5 : 1.0)
.animation(.easeInOut(duration: 1).repeatForever(), value: heartBeating)
.task { heartBeating = true }
}
.font(.largeTitle)
}
}
struct ItemListView : View {
@Environment(ViewModel.self) var viewModel: ViewModel
var body: some View {
List {
ForEach(viewModel.items) { item in
NavigationLink(value: item) {
Label {
Text(item.itemTitle)
} icon: {
if item.favorite {
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
}
}
.onDelete { offsets in
viewModel.items.remove(atOffsets: offsets)
}
.onMove { fromOffsets, toOffset in
viewModel.items.move(fromOffsets: fromOffsets, toOffset: toOffset)
}
}
.navigationDestination(for: Item.self) { item in
ItemView(item: item)
.navigationTitle(item.itemTitle)
}
.toolbar {
ToolbarItemGroup {
Button {
withAnimation {
viewModel.items.insert(Item(), at: 0)
}
} label: {
Label("Add", systemImage: "plus")
}
}
switch root {
case .auth:
AuthFlowView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore)
case .main:
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore)
}
}
}
struct ItemView : View {
@State var item: Item
@Environment(ViewModel.self) var viewModel: ViewModel
@Environment(\.dismiss) var dismiss
var body: some View {
Form {
TextField("Title", text: $item.title)
.textFieldStyle(.roundedBorder)
Toggle("Favorite", isOn: $item.favorite)
DatePicker("Date", selection: $item.date)
Text("Notes").font(.title3)
TextEditor(text: $item.notes)
.border(Color.secondary, width: 1.0)
}
.navigationBarBackButtonHidden()
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
viewModel.save(item: item)
dismiss()
}
.disabled(!viewModel.isUpdated(item))
}
}
}
}
struct SettingsView : View {
@Binding var appearance: String
@Binding var welcomeName: String
var body: some View {
Form {
TextField("Name", text: $welcomeName)
Picker("Appearance", selection: $appearance) {
Text("System").tag("")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
Text("Version \(version) (\(buildNumber))")
}
HStack {
PlatformHeartView()
Text("Powered by [Skip](https://skip.tools)")
}
}
}
}
/// A view that shows a blue heart on iOS and a green heart on Android.
struct PlatformHeartView : View {
var body: some View {
#if os(Android)
ComposeView {
HeartComposer()
}
#else
Text(verbatim: "💙")
#endif
}
}
#if SKIP
/// Use a ContentComposer to integrate Compose content. This code will be transpiled to Kotlin.
struct HeartComposer : ContentComposer {
@Composable func Compose(context: ComposeContext) {
androidx.compose.material3.Text("💚", modifier: context.modifier)
}
}
#endif

View File

@@ -0,0 +1,10 @@
enum RootFlow: Hashable {
case auth
case main
}
enum MainTab: Hashable {
case home
case cart
case profile
}

View File

@@ -0,0 +1,101 @@
import SwiftUI
// MARK: - Colors
enum AppColors {
static let primary = Color(hex: "#1E6B43")
static let secondary = Color(hex: "#2F7C4B")
static let tertiary = Color(hex: "#8CFF2E")
static let brandDark = Color(hex: "#1B5C3A")
static let brandSoft = Color(hex: "#E8F2EC")
static let backgroundLight = Color(hex: "#F3F5F7")
static let backgroundDark = Color(hex: "#18230F")
static let surface = Color(hex: "#FFFFFF")
static let textPrimary = Color(hex: "#1C1F23")
static let textInverse = Color(hex: "#FFFFFF")
static let textMuted = Color(hex: "#7B8794")
}
enum AppDarkColors {
static let background = Color(hex: "#18230F")
static let surface = Color(hex: "#FFFFFF", alpha: 0.05)
static let textPrimary = Color(hex: "#FFFFFF")
static let textSecondary = Color(hex: "#A3A3A3")
static let primary = AppColors.primary
static let secondary = AppColors.secondary
static let tertiary = AppColors.tertiary
}
// MARK: - Typography
enum AppTypography {
static let fontFamily = "Plus Jakarta Sans"
static let heading1 = Font.custom(fontFamily, size: 28).weight(.heavy)
static let heading2 = Font.custom(fontFamily, size: 20).weight(.heavy)
static let heading3 = Font.custom(fontFamily, size: 16).weight(.bold)
static let body = Font.custom(fontFamily, size: 16).weight(.regular)
static let button = Font.custom(fontFamily, size: 14).weight(.heavy)
static let caption = Font.custom(fontFamily, size: 10).weight(.bold)
static let overline = Font.custom(fontFamily, size: 11).weight(.bold)
static let bodyLineHeight: CGFloat = 1.6
static let buttonLetterSpacing: CGFloat = 0.08
static let captionLetterSpacing: CGFloat = 0.12
}
// MARK: - Layout
enum AppLayout {
static let radiusMD: CGFloat = 12
static let radiusLG: CGFloat = 16
static let radiusXL: CGFloat = 24
static let radiusFull: CGFloat = 9999
static let spacing: [CGFloat] = [4, 8, 12, 16, 24, 32]
}
// MARK: - Shadow
struct ShadowSpec {
let color: Color
let radius: CGFloat
let y: CGFloat
}
enum AppShadow {
static let soft = ShadowSpec(color: Color.black.opacity(0.08), radius: 20, y: 6)
static let glow = ShadowSpec(color: AppColors.tertiary.opacity(0.2), radius: 24, y: 8)
}
// MARK: - Helpers
extension Color {
init(hex: String, alpha: Double = 1.0) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&int)
let r, g, b: UInt64
switch cleaned.count {
case 6: // RRGGBB
r = (int >> 16) & 0xFF
g = (int >> 8) & 0xFF
b = int & 0xFF
case 3: // RGB
r = ((int >> 8) & 0xF) * 17
g = ((int >> 4) & 0xF) * 17
b = (int & 0xF) * 17
default:
r = 0; g = 0; b = 0
}
self.init(.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: alpha)
}
}

View File

@@ -1,8 +1,17 @@
{
"sourceLanguage" : "en",
"strings" : {
"·" : {
"comment" : "A period character used to separate different pieces of information in a list.",
"isCommentAutoGenerated" : true
},
"(%@)" : {
"comment" : "A text element showing the number of reviews for a store. The content inside the parentheses is the number of reviews.",
"isCommentAutoGenerated" : true
},
"%lld Items" : {
"comment" : "Header title for a list that contains the number of items",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -32,6 +41,7 @@
},
"Add" : {
"comment" : "Button in items list that will cause a new item to be added",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -59,8 +69,13 @@
}
}
},
"Ajuda" : {
"comment" : "A link to the help section of the app.",
"isCommentAutoGenerated" : true
},
"Appearance" : {
"comment" : "Settings select label for the interface style of the controls (light, dark, or default)",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -88,8 +103,13 @@
}
}
},
"Cadastro" : {
"comment" : "A label displayed above the registration form.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
"comment" : "Button title indicating that the operation should be cancelled",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -117,8 +137,33 @@
}
}
},
"Canggu, Kuta Utara, Bali" : {
"comment" : "The address of the user's delivery location.",
"isCommentAutoGenerated" : true
},
"Carrinho" : {
"comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true
},
"Categories" : {
"comment" : "A section header that lists available food categories.",
"isCommentAutoGenerated" : true
},
"Criar conta" : {
"comment" : "A link that navigates to the registration screen.",
"isCommentAutoGenerated" : true
},
"Daniel Loverde" : {
"comment" : "The name of the user in the profile view header.",
"isCommentAutoGenerated" : true
},
"daniel@pedifoods.com.br" : {
"comment" : "An email address displayed in the user's profile.",
"isCommentAutoGenerated" : true
},
"Dark" : {
"comment" : "Menu item indicating that the appearance should be in dark mode",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -148,6 +193,7 @@
},
"Date" : {
"comment" : "Item editor form label for the Date field",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -175,8 +221,33 @@
}
}
},
"DELIVERY LOCATION" : {
"comment" : "A label describing the delivery location section.",
"isCommentAutoGenerated" : true
},
"Descubra lorem ipsum ba bla" : {
"comment" : "A welcome message displayed on the login screen.",
"isCommentAutoGenerated" : true
},
"DL" : {
"comment" : "An abbreviation for \"Delivery Lady\" used in the user's profile picture circle.",
"isCommentAutoGenerated" : true
},
"Email" : {
"comment" : "A text field for entering the user's email address.",
"isCommentAutoGenerated" : true
},
"Enderecos" : {
"comment" : "A link to the user's address list.",
"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.",
"isCommentAutoGenerated" : true
},
"Favorite" : {
"comment" : "Item editor title label for marking the item as a favorite",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -206,6 +277,7 @@
},
"Hello [%@](https://skip.tools)!" : {
"comment" : "Welcome tab contents",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -233,6 +305,10 @@
}
}
},
"Historico vazio" : {
"comment" : "A description displayed when a user has no orders in their history.",
"isCommentAutoGenerated" : true
},
"Home" : {
"comment" : "Tab bar item title for the Home tab",
"localizations" : {
@@ -264,6 +340,7 @@
},
"Light" : {
"comment" : "Menu item indicating that the appearance should be in light mode",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -293,6 +370,7 @@
},
"Name" : {
"comment" : "Placeholder title for the Name field in a form",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -320,8 +398,16 @@
}
}
},
"Não tem conta ainda?" : {
},
"Nome" : {
"comment" : "A label for the user to input their name.",
"isCommentAutoGenerated" : true
},
"Notes" : {
"comment" : "Item editor form label for the Notes field",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -349,8 +435,29 @@
}
}
},
"O que vai querer pedir hoje?" : {
"comment" : "A heading displayed above a search bar in the home view.",
"isCommentAutoGenerated" : true
},
"OTP" : {
"comment" : "The title of a view that asks the user to enter an OTP code.",
"isCommentAutoGenerated" : true
},
"Pedidos" : {
"comment" : "Navigation links for different sections of the user's profile.",
"isCommentAutoGenerated" : true
},
"PediFoods" : {
"comment" : "The name of the app.",
"isCommentAutoGenerated" : true
},
"Perfil" : {
"comment" : "A label for the \"Profile\" tab in the main tab view.",
"isCommentAutoGenerated" : true
},
"Powered by [Skip](https://skip.tools)" : {
"comment" : "Link markdown text for the Powered by… label",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -380,6 +487,7 @@
},
"Save" : {
"comment" : "Button title indicating that the current contents should be saved",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -409,6 +517,7 @@
},
"Settings" : {
"comment" : "Tab bar item title for the Settings tab",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -436,8 +545,13 @@
}
}
},
"Seu carrinho esta vazio" : {
"comment" : "A description displayed when a user's cart is empty.",
"isCommentAutoGenerated" : true
},
"System" : {
"comment" : "Menu item indicating that the appearance should be in the default system mode",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -465,8 +579,13 @@
}
}
},
"Telefone" : {
"comment" : "A label for a text field where a user can enter their phone number.",
"isCommentAutoGenerated" : true
},
"Title" : {
"comment" : "Label for the item editor form indicating the title of the item",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -496,6 +615,7 @@
},
"Version %@ (%@)" : {
"comment" : "Settings label showing the current version of the app",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
@@ -531,6 +651,7 @@
},
"Welcome" : {
"comment" : "Tab bar item title for the Welcome tab",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -559,5 +680,5 @@
}
}
},
"version" : "1.0"
}
"version" : "1.1"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -0,0 +1,94 @@
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:0.99020,0.98039,1.00000,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"solid" : "srgb:0.00000,0.00000,0.00000,1.00000"
}
}
],
"groups" : [
{
"layers" : [
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
},
{
"layers" : [
{
"blend-mode-specializations" : [
{
"value" : "normal"
},
{
"appearance" : "dark",
"value" : "normal"
}
],
"fill-specializations" : [
{
"value" : "none"
},
{
"appearance" : "dark",
"value" : {
"solid" : "srgb:0.00000,0.50000,0.04260,1.00000"
}
}
],
"glass" : false,
"hidden" : false,
"image-name" : "pedifoods_big.png",
"name" : "pedifoods_big",
"position-specializations" : [
{
"value" : {
"scale" : 1,
"translation-in-points" : [
137.390625,
11.984375
]
}
},
{
"idiom" : "square",
"value" : {
"scale" : 0.35,
"translation-in-points" : [
8,
0
]
}
}
]
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : [
"iOS"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,104 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
enum NetworkError: Error, LocalizedError {
case invalidURL
case invalidResponse
case httpError(Int, String?)
case decodeError
case rateLimited
var errorDescription: String? {
switch self {
case .invalidURL: return "URL invalida"
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."
}
}
}
struct ApiRequest {
let path: String
let method: String
let module: ApiModule
let requiresAuth: Bool
let queryItems: [URLQueryItem]
let body: Data?
init(path: String,
method: String = "GET",
module: ApiModule = .none,
requiresAuth: Bool = true,
queryItems: [URLQueryItem] = [],
body: Data? = nil) {
self.path = path
self.method = method
self.module = module
self.requiresAuth = requiresAuth
self.queryItems = queryItems
self.body = body
}
}
final class ApiClient {
private let session: URLSession
private let tokenStore: TokenStore
init(session: URLSession = .shared, tokenStore: TokenStore = DefaultTokenStore()) {
self.session = session
self.tokenStore = tokenStore
}
func send<T: Decodable>(_ request: ApiRequest) async throws -> T {
let url = try buildURL(path: request.path, query: request.queryItems)
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
urlRequest.httpBody = request.body
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = ApiConfig.token(for: request.module) {
urlRequest.setValue(token, forHTTPHeaderField: "Atomenta-Token")
}
if request.requiresAuth, let jwt = tokenStore.jwt {
urlRequest.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: urlRequest)
guard let http = response as? HTTPURLResponse else {
throw NetworkError.invalidResponse
}
if http.statusCode == 429 {
throw NetworkError.rateLimited
}
if !(200...299).contains(http.statusCode) {
let message = String(data: data, encoding: .utf8)
throw NetworkError.httpError(http.statusCode, message)
}
do {
return try JSONDecoder().decode(T.self, from: data)
} catch {
throw NetworkError.decodeError
}
}
private func buildURL(path: String, query: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: ApiConfig.baseURL, resolvingAgainstBaseURL: false) else {
throw NetworkError.invalidURL
}
components.path = components.path.appending(path)
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else { throw NetworkError.invalidURL }
return url
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
enum ApiModule {
case app
case customer
case store
case resource
case none
}
enum ApiConfig {
static var baseURL: URL {
let raw = ProcessInfo.processInfo.environment["ATOMENTA_API_URL"] ?? "http://127.0.0.1:3001"
return URL(string: raw) ?? URL(string: "http://127.0.0.1:3001")!
}
// Tokens provided by backend modules
static let storeToken = "550e8400-e29b-41d4-a716-446655440008"
static let customerToken = "550e8400-e29b-41d4-a716-44665544000a"
static let resourceToken = "550e8400-e29b-41d4-a716-446655440009"
static func token(for module: ApiModule) -> String? {
switch module {
case .store: return storeToken
case .customer: return customerToken
case .resource: return resourceToken
case .app, .none: return nil
}
}
}

View File

@@ -0,0 +1,88 @@
import Foundation
struct ApiEnvelope<T: Decodable>: Decodable {
let error: Bool
let code: String?
let message: String?
let result: T?
}
final class ApiService {
private let client: ApiClient
private var tokenStore: TokenStore
init(client: ApiClient = ApiClient(), tokenStore: TokenStore = DefaultTokenStore()) {
self.client = client
self.tokenStore = tokenStore
}
// 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)
}
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)
if let token = response.result?.token {
tokenStore.jwt = token
}
return response
}
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)
}
// MARK: - Stores
func listStores(lat: Double, lng: Double, category: String? = nil, search: String? = nil) async throws -> ApiEnvelope<[StoreSummary]> {
var items = [
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng))
]
if let category {
items.append(URLQueryItem(name: "category", value: category))
}
if let search {
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)
}
}
// MARK: - DTOs
struct EmptyResult: Decodable {}
struct LoginResult: Decodable {
let token: String
let customer: CustomerProfile?
}
struct CustomerProfile: Decodable {
let id: String
let name: String
let email: String
let phoneNumber: String?
}
struct StoreSummary: Decodable {
let id: String
let name: String
let logo: String?
let cover: String?
let category: String?
let rating: Double?
let deliveryTime: String?
let deliveryFee: Double?
let distance: Double?
let isOpen: Bool?
let statusLabel: String?
}

View File

@@ -0,0 +1,20 @@
import Foundation
protocol TokenStore: AnyObject {
var jwt: String? { get set }
func clear()
}
final class DefaultTokenStore: TokenStore {
private let key = "auth_jwt"
private let defaults = UserDefaults.standard
var jwt: String? {
get { defaults.string(forKey: key) }
set { defaults.set(newValue, forKey: key) }
}
func clear() {
defaults.removeObject(forKey: key)
}
}

View File

@@ -0,0 +1,149 @@
import SwiftUI
struct AuthFlowView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
var body: some View {
NavigationStack {
LoginView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore)
}
}
}
struct LoginView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@State var email = ""
@Environment(\.colorScheme) var colorScheme
@ViewBuilder private var logoImage: some View {
#if os(Android)
Image(colorScheme == .dark ? "pedifoods_dark" : "pedifoods", bundle: .module)
.resizable()
#else
SwiftUI.Image("pedifoods")
.resizable()
#endif
}
@ViewBuilder private var pinHeroImage: some View {
#if os(Android)
Image(colorScheme == .dark ? "pin_image_app_dark" : "pin_image_app", bundle: .module)
.resizable()
#else
SwiftUI.Image("pin_image_app")
.resizable()
#endif
}
var body: some View {
ZStack(alignment: .top) {
(colorScheme == .dark ? AppColors.backgroundDark : AppColors.backgroundLight)
.ignoresSafeArea()
pinHeroImage
.scaledToFill()
.frame(height: 520)
.offset(y: -90)
.mask(
LinearGradient(
colors: [.black, .black, .black.opacity(0.0)],
startPoint: .top,
endPoint: .bottom
)
)
VStack(spacing: 20) {
Spacer().frame(height: 280)
logoImage
.scaledToFit()
.frame(height: 200)
Text("Descubra lorem ipsum ba bla")
.font(AppTypography.heading1)
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse : AppColors.textPrimary)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
Spacer()
PrimaryButton(title: "ENTRAR") {
// TODO: integrar com ApiService e OTP
tokenStore.jwt = "demo-token"
selectedTab = .home
root = .main
}
.padding(.horizontal, 28)
HStack(spacing: 6) {
Text("Não tem conta ainda?")
.foregroundStyle(colorScheme == .dark ? AppColors.textInverse.opacity(0.9) : AppColors.textPrimary)
NavigationLink("Criar conta") {
RegistrationView(root: $root, selectedTab: $selectedTab, tokenStore: tokenStore)
}
.foregroundStyle(AppColors.primary)
}
.font(AppTypography.body)
Spacer().frame(height: 24)
}
}
.ignoresSafeArea()
}
}
struct RegistrationView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
@State var name = ""
@State var email = ""
@State var phone = ""
var body: some View {
ScrollView {
VStack(spacing: 20) {
Text("Cadastro")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
VStack(spacing: 12) {
TextField("Nome", text: $name)
.textFieldStyle(.roundedBorder)
TextField("Email", text: $email)
.textFieldStyle(.roundedBorder)
TextField("Telefone", text: $phone)
.textFieldStyle(.roundedBorder)
PrimaryButton(title: "Enviar OTP") {
// TODO: solicitar OTP
}
}
}
.padding(24)
}
.background(AppColors.backgroundLight)
}
}
struct OtpView: View {
let email: String
var body: some View {
VStack(spacing: 16) {
Text("OTP")
.font(AppTypography.heading1)
.foregroundStyle(AppColors.textPrimary)
Text("Enviado para \(email)")
.font(.caption)
.foregroundStyle(AppColors.secondary)
}
.padding(24)
.background(AppColors.backgroundLight)
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct CartView: View {
var body: some View {
VStack(spacing: 16) {
Text("Carrinho")
.font(AppTypography.heading1)
Text("Seu carrinho esta vazio")
.foregroundStyle(AppColors.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
}

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

View File

@@ -0,0 +1,30 @@
import SwiftUI
struct MainTabView: View {
@Binding var selectedTab: MainTab
@Binding var root: RootFlow
let tokenStore: TokenStore
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView()
}
.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)
}
.tabItem { Label("Perfil", systemImage: "person.fill") }
.tag(MainTab.profile)
}
.tint(AppColors.primary)
}
}

View File

@@ -0,0 +1,74 @@
import SwiftUI
struct ProfileView: View {
@Binding var root: RootFlow
@Binding var selectedTab: MainTab
let tokenStore: TokenStore
var body: some View {
VStack(spacing: 20) {
header
VStack(spacing: 12) {
NavigationLink("Pedidos") {
OrdersView()
}
NavigationLink("Enderecos") {
Text("Enderecos")
}
NavigationLink("Ajuda") {
Text("Ajuda")
}
}
.font(AppTypography.body)
.foregroundStyle(AppColors.textPrimary)
Spacer()
SecondaryButton(title: "Sair") {
tokenStore.clear()
root = .auth
}
.padding(.horizontal, 20)
}
.padding(.top, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
private var header: some View {
VStack(spacing: 8) {
Circle()
.fill(AppColors.primary)
.frame(width: 72, height: 72)
.overlay(
Text("DL")
.font(.headline)
.foregroundStyle(AppColors.textInverse)
)
Text("Daniel Loverde")
.font(AppTypography.heading2)
.foregroundStyle(AppColors.textPrimary)
Text("daniel@pedifoods.com.br")
.font(.caption)
.foregroundStyle(AppColors.secondary)
}
}
}
struct OrdersView: View {
var body: some View {
VStack(spacing: 16) {
Text("Pedidos")
.font(AppTypography.heading1)
Text("Historico vazio")
.foregroundStyle(AppColors.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(AppColors.backgroundLight)
}
}

View File

@@ -14,12 +14,11 @@ Este arquivo deve ser usado como **prompt base canônico** para qualquer IA, des
```ts
colors: {
primary: "#8CFF2E", // ação principal, CTAs, destaque
secondary: "#388E3C", // ações secundárias, títulos
primary: "#388E3C", // ação principal, CTAs, destaque
backgroundLight: "#F7F8F5",
backgroundDark: "#18230F",
textPrimary: "#333333",
textInverse: "#FFFFFF"
textPrimary: "#FFFFFF",
textInverse: "#333333"
}
```
@@ -31,21 +30,6 @@ Uso:
---
### 1.2 🌙 Dark Theme
Dark mode nativo, não invertido.
```ts
dark: {
background: "#18230F",
surface: "rgba(255,255,255,0.05)",
textPrimary: "#FFFFFF",
textSecondary: "#A3A3A3",
primary: "#8CFF2E",
secondary: "#388E3C"
}
```
---
### 1.3 🔤 Tipografia
@@ -70,7 +54,7 @@ typography: {
body: {
size: "16px",
weight: 400,
lineHeight: "1.6"
lineHeight: "0"
},
button: {
@@ -195,30 +179,3 @@ Icon {
active?: boolean
}
```
---
## 3. IMPLEMENTAÇÃO (TAILWIND-FIRST)
### 3.1 Tokens no Tailwind
```ts
theme.extend.colors = {
primary: "#8CFF2E",
secondary: "#388E3C",
backgroundLight: "#F7F8F5",
backgroundDark: "#18230F",
typographyDark: "#333333"
}
```
---
## 4. PRINCÍPIOS DO SISTEMA
- Mobile-first
- Tailwind-first
- Dark mode por classe
- Tokens semânticos
- UI energética e saudável
- Feedback visual imediato

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB