Login and Home
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 565 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 897 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
52
pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
pedi-foods/Darwin/Assets.xcassets/login_bg.imageset/login_light.png
vendored
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
52
pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods.png
vendored
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
pedi-foods/Darwin/Assets.xcassets/pedifoods.imageset/pedifoods_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 12 KiB |
52
pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/Contents.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
pedi-foods/Darwin/Assets.xcassets/pin_image_app.imageset/pin_image_app_dark.png
vendored
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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;
|
||||
|
||||
44
pedi-foods/Sources/PediFoods/Components/Buttons.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
44
pedi-foods/Sources/PediFoods/Components/Inputs.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
131
pedi-foods/Sources/PediFoods/Components/StoreCard.swift
Normal 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]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
enum RootFlow: Hashable {
|
||||
case auth
|
||||
case main
|
||||
}
|
||||
|
||||
enum MainTab: Hashable {
|
||||
case home
|
||||
case cart
|
||||
case profile
|
||||
}
|
||||
101
pedi-foods/Sources/PediFoods/DesignSystem/DesignSystem.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
BIN
pedi-foods/Sources/PediFoods/Resources/login_dark.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
pedi-foods/Sources/PediFoods/Resources/login_light.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 120 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
pedi-foods/Sources/PediFoods/Resources/pedifoods.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/pedifoods_dark.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
pedi-foods/Sources/PediFoods/Resources/pin_image_app.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
pedi-foods/Sources/PediFoods/Resources/pin_image_app_dark.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
104
pedi-foods/Sources/PediFoods/Services/ApiClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
30
pedi-foods/Sources/PediFoods/Services/ApiConfig.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
88
pedi-foods/Sources/PediFoods/Services/ApiService.swift
Normal 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?
|
||||
}
|
||||
20
pedi-foods/Sources/PediFoods/Services/TokenStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
149
pedi-foods/Sources/PediFoods/Views/Auth/AuthFlowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
15
pedi-foods/Sources/PediFoods/Views/Main/CartView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
30
pedi-foods/Sources/PediFoods/Views/Main/MainTabView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
74
pedi-foods/Sources/PediFoods/Views/Main/ProfileView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||