Initial commit: PayfritFood iOS app

- SwiftUI + async/await architecture
- Barcode scanning with AVFoundation
- Product display with score ring, NOVA badge, nutrition
- Alternatives with sort/filter
- Auth (login/register)
- Favorites & history
- Account management
- Dark theme
- Connected to food.payfrit.com API (Open Food Facts proxy)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John Pinkyfloyd 2026-03-16 16:58:21 -07:00
commit 71e7ec34f6
36 changed files with 4004 additions and 0 deletions

129
CLAUDE.md Normal file
View file

@ -0,0 +1,129 @@
# PayfritFood - iOS App
Native Swift/SwiftUI iOS app for scanning food products, viewing health scores, and finding healthier alternatives.
**Bundle ID**: `com.payfrit.food`
**Display Name**: Payfrit Food
**API Base**: `https://food.payfrit.com/api`
## Build & Deploy
```bash
# Build for device
cd ~/payfrit-food-ios && xcodebuild -project PayfritFood.xcodeproj -scheme PayfritFood -destination 'id=00008030-000244863413C02E' -allowProvisioningUpdates build
# Install to phone
xcrun devicectl device install app --device 00008030-000244863413C02E ~/Library/Developer/Xcode/DerivedData/PayfritFood-*/Build/Products/Debug-iphoneos/PayfritFood.app
# Launch app
xcrun devicectl device process launch --device 00008030-000244863413C02E com.payfrit.food
```
## Project Structure
```
PayfritFood/
├── PayfritFoodApp.swift # App entry point
├── Info.plist # App config, permissions
├── Assets.xcassets/ # App icons, colors
├── Models/
│ ├── Product.swift # Product with score, NOVA, nutrition
│ ├── Alternative.swift # Alternative product with links
│ ├── UserProfile.swift # User profile
│ └── ScanHistory.swift # Scan history item
├── ViewModels/
│ └── AppState.swift # Central state (@MainActor ObservableObject)
├── Services/
│ ├── APIService.swift # Actor-based API client
│ ├── AuthStorage.swift # Keychain token storage
│ ├── BarcodeScanner.swift # AVFoundation barcode scanning
│ └── LocationService.swift # CoreLocation for distance
└── Views/
├── RootView.swift # Tab navigation
├── ScanTab/
│ ├── ScanScreen.swift # Camera + manual entry
│ └── ManualEntrySheet.swift
├── ProductTab/
│ ├── ProductScreen.swift # Product detail
│ ├── ScoreRing.swift # Animated score circle
│ ├── NOVABadge.swift # NOVA 1-4 badge
│ ├── DietaryPills.swift # Dietary tags
│ ├── NutritionSection.swift
│ └── IngredientsSection.swift
├── AlternativesTab/
│ ├── AlternativesScreen.swift
│ ├── FilterChips.swift
│ ├── AlternativeCard.swift
│ └── SponsoredCard.swift
├── FavoritesTab/
│ └── FavoritesScreen.swift
├── HistoryTab/
│ └── HistoryScreen.swift
├── AccountTab/
│ ├── AccountScreen.swift
│ ├── LoginSheet.swift
│ └── RegisterSheet.swift
└── Components/
└── ProductCard.swift
```
## Architecture
### State Management
- **AppState** (`@MainActor ObservableObject`): Single source of truth
- Authentication: userId, userToken, userProfile, isPremium
- Current product and alternatives
- Navigation state (selectedTab, showLoginSheet, etc.)
### API Service
- **APIService** (Swift actor): Thread-safe singleton
- Base URL: `https://food.payfrit.com/api`
- Auth via `Authorization: Bearer {token}` header
### Key Endpoints
- `POST /auth/login` - Login with email/password
- `POST /auth/register` - Register new user
- `POST /product/lookup` - Lookup product by barcode
- `GET /product/{id}/alternatives` - Get healthier alternatives
- `GET /favorites` - Get user favorites
- `GET /history` - Get scan history
## Features
### Barcode Scanning
- AVFoundation with AVCaptureMetadataOutput
- Supported formats: EAN-8, EAN-13, UPC-A, UPC-E, Code-128
- Manual entry fallback
### Product Display
- Animated score ring (0-100)
- NOVA badge (1-4 processing level)
- Dietary pills (vegan, gluten-free, etc.)
- Expandable nutrition facts
- Expandable ingredients list
### Alternatives
- Sort by: Rating, Price, Distance, Processing Level
- Filter chips: Dietary (vegan, GF, etc.), Availability (delivery, pickup)
- Sponsored cards with action links (premium users see no sponsored content)
### User Features
- Favorites (requires auth)
- Scan history (requires auth)
- Account: export data, logout, delete account
- Premium: removes sponsored content
## Permissions
- **Camera**: Barcode scanning
- **Location**: Find nearby stores
## Dependencies
- None (uses only system frameworks)
## iOS Version
- Minimum: iOS 16.0
- SwiftUI + async/await + URLSession

View file

@ -0,0 +1,545 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
F0000001 /* PayfritFoodApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001 /* PayfritFoodApp.swift */; };
F0000002 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1000002 /* Assets.xcassets */; };
F0000010 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000010 /* AppState.swift */; };
F0000011 /* APIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000011 /* APIService.swift */; };
F0000012 /* AuthStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000012 /* AuthStorage.swift */; };
F0000013 /* BarcodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000013 /* BarcodeScanner.swift */; };
F0000014 /* LocationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000014 /* LocationService.swift */; };
F0000020 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000020 /* Product.swift */; };
F0000021 /* Alternative.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000021 /* Alternative.swift */; };
F0000022 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000022 /* UserProfile.swift */; };
F0000023 /* ScanHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000023 /* ScanHistory.swift */; };
F0000030 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000030 /* RootView.swift */; };
F0000031 /* ScanScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000031 /* ScanScreen.swift */; };
F0000032 /* ManualEntrySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000032 /* ManualEntrySheet.swift */; };
F0000033 /* ProductScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000033 /* ProductScreen.swift */; };
F0000034 /* ScoreRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000034 /* ScoreRing.swift */; };
F0000035 /* NOVABadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000035 /* NOVABadge.swift */; };
F0000036 /* DietaryPills.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000036 /* DietaryPills.swift */; };
F0000037 /* NutritionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000037 /* NutritionSection.swift */; };
F0000038 /* IngredientsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000038 /* IngredientsSection.swift */; };
F0000039 /* AlternativesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000039 /* AlternativesScreen.swift */; };
F0000040 /* FilterChips.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000040 /* FilterChips.swift */; };
F0000041 /* AlternativeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000041 /* AlternativeCard.swift */; };
F0000042 /* SponsoredCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000042 /* SponsoredCard.swift */; };
F0000043 /* FavoritesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000043 /* FavoritesScreen.swift */; };
F0000044 /* HistoryScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000044 /* HistoryScreen.swift */; };
F0000045 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000045 /* AccountScreen.swift */; };
F0000046 /* LoginSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000046 /* LoginSheet.swift */; };
F0000047 /* RegisterSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000047 /* RegisterSheet.swift */; };
F0000048 /* ProductCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000048 /* ProductCard.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
F1000000 /* PayfritFood.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PayfritFood.app; sourceTree = BUILT_PRODUCTS_DIR; };
F1000001 /* PayfritFoodApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PayfritFoodApp.swift; sourceTree = "<group>"; };
F1000002 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F1000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
F1000010 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
F1000011 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = "<group>"; };
F1000012 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = "<group>"; };
F1000013 /* BarcodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScanner.swift; sourceTree = "<group>"; };
F1000014 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = "<group>"; };
F1000020 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = "<group>"; };
F1000021 /* Alternative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alternative.swift; sourceTree = "<group>"; };
F1000022 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
F1000023 /* ScanHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanHistory.swift; sourceTree = "<group>"; };
F1000030 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
F1000031 /* ScanScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanScreen.swift; sourceTree = "<group>"; };
F1000032 /* ManualEntrySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntrySheet.swift; sourceTree = "<group>"; };
F1000033 /* ProductScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductScreen.swift; sourceTree = "<group>"; };
F1000034 /* ScoreRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreRing.swift; sourceTree = "<group>"; };
F1000035 /* NOVABadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NOVABadge.swift; sourceTree = "<group>"; };
F1000036 /* DietaryPills.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DietaryPills.swift; sourceTree = "<group>"; };
F1000037 /* NutritionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NutritionSection.swift; sourceTree = "<group>"; };
F1000038 /* IngredientsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngredientsSection.swift; sourceTree = "<group>"; };
F1000039 /* AlternativesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternativesScreen.swift; sourceTree = "<group>"; };
F1000040 /* FilterChips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterChips.swift; sourceTree = "<group>"; };
F1000041 /* AlternativeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternativeCard.swift; sourceTree = "<group>"; };
F1000042 /* SponsoredCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsoredCard.swift; sourceTree = "<group>"; };
F1000043 /* FavoritesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesScreen.swift; sourceTree = "<group>"; };
F1000044 /* HistoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryScreen.swift; sourceTree = "<group>"; };
F1000045 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = "<group>"; };
F1000046 /* LoginSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSheet.swift; sourceTree = "<group>"; };
F1000047 /* RegisterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterSheet.swift; sourceTree = "<group>"; };
F1000048 /* ProductCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCard.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
F2000001 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
F3000000 = {
isa = PBXGroup;
children = (
F3000001 /* PayfritFood */,
F3000002 /* Products */,
);
sourceTree = "<group>";
};
F3000001 /* PayfritFood */ = {
isa = PBXGroup;
children = (
F1000001 /* PayfritFoodApp.swift */,
F1000003 /* Info.plist */,
F1000002 /* Assets.xcassets */,
F3000010 /* Models */,
F3000011 /* ViewModels */,
F3000012 /* Services */,
F3000013 /* Views */,
);
path = PayfritFood;
sourceTree = "<group>";
};
F3000002 /* Products */ = {
isa = PBXGroup;
children = (
F1000000 /* PayfritFood.app */,
);
name = Products;
sourceTree = "<group>";
};
F3000010 /* Models */ = {
isa = PBXGroup;
children = (
F1000020 /* Product.swift */,
F1000021 /* Alternative.swift */,
F1000022 /* UserProfile.swift */,
F1000023 /* ScanHistory.swift */,
);
path = Models;
sourceTree = "<group>";
};
F3000011 /* ViewModels */ = {
isa = PBXGroup;
children = (
F1000010 /* AppState.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
F3000012 /* Services */ = {
isa = PBXGroup;
children = (
F1000011 /* APIService.swift */,
F1000012 /* AuthStorage.swift */,
F1000013 /* BarcodeScanner.swift */,
F1000014 /* LocationService.swift */,
);
path = Services;
sourceTree = "<group>";
};
F3000013 /* Views */ = {
isa = PBXGroup;
children = (
F1000030 /* RootView.swift */,
F3000020 /* ScanTab */,
F3000021 /* ProductTab */,
F3000022 /* AlternativesTab */,
F3000023 /* FavoritesTab */,
F3000024 /* HistoryTab */,
F3000025 /* AccountTab */,
F3000026 /* Components */,
);
path = Views;
sourceTree = "<group>";
};
F3000020 /* ScanTab */ = {
isa = PBXGroup;
children = (
F1000031 /* ScanScreen.swift */,
F1000032 /* ManualEntrySheet.swift */,
);
path = ScanTab;
sourceTree = "<group>";
};
F3000021 /* ProductTab */ = {
isa = PBXGroup;
children = (
F1000033 /* ProductScreen.swift */,
F1000034 /* ScoreRing.swift */,
F1000035 /* NOVABadge.swift */,
F1000036 /* DietaryPills.swift */,
F1000037 /* NutritionSection.swift */,
F1000038 /* IngredientsSection.swift */,
);
path = ProductTab;
sourceTree = "<group>";
};
F3000022 /* AlternativesTab */ = {
isa = PBXGroup;
children = (
F1000039 /* AlternativesScreen.swift */,
F1000040 /* FilterChips.swift */,
F1000041 /* AlternativeCard.swift */,
F1000042 /* SponsoredCard.swift */,
);
path = AlternativesTab;
sourceTree = "<group>";
};
F3000023 /* FavoritesTab */ = {
isa = PBXGroup;
children = (
F1000043 /* FavoritesScreen.swift */,
);
path = FavoritesTab;
sourceTree = "<group>";
};
F3000024 /* HistoryTab */ = {
isa = PBXGroup;
children = (
F1000044 /* HistoryScreen.swift */,
);
path = HistoryTab;
sourceTree = "<group>";
};
F3000025 /* AccountTab */ = {
isa = PBXGroup;
children = (
F1000045 /* AccountScreen.swift */,
F1000046 /* LoginSheet.swift */,
F1000047 /* RegisterSheet.swift */,
);
path = AccountTab;
sourceTree = "<group>";
};
F3000026 /* Components */ = {
isa = PBXGroup;
children = (
F1000048 /* ProductCard.swift */,
);
path = Components;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
F4000001 /* PayfritFood */ = {
isa = PBXNativeTarget;
buildConfigurationList = F6000001 /* Build configuration list for PBXNativeTarget "PayfritFood" */;
buildPhases = (
F2000002 /* Sources */,
F2000001 /* Frameworks */,
F2000003 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = PayfritFood;
productName = PayfritFood;
productReference = F1000000 /* PayfritFood.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
F5000001 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1500;
LastUpgradeCheck = 1500;
TargetAttributes = {
F4000001 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = F6000000 /* Build configuration list for PBXProject "PayfritFood" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = F3000000;
productRefGroup = F3000002 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
F4000001 /* PayfritFood */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
F2000003 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F0000002 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F2000002 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F0000001 /* PayfritFoodApp.swift in Sources */,
F0000010 /* AppState.swift in Sources */,
F0000011 /* APIService.swift in Sources */,
F0000012 /* AuthStorage.swift in Sources */,
F0000013 /* BarcodeScanner.swift in Sources */,
F0000014 /* LocationService.swift in Sources */,
F0000020 /* Product.swift in Sources */,
F0000021 /* Alternative.swift in Sources */,
F0000022 /* UserProfile.swift in Sources */,
F0000023 /* ScanHistory.swift in Sources */,
F0000030 /* RootView.swift in Sources */,
F0000031 /* ScanScreen.swift in Sources */,
F0000032 /* ManualEntrySheet.swift in Sources */,
F0000033 /* ProductScreen.swift in Sources */,
F0000034 /* ScoreRing.swift in Sources */,
F0000035 /* NOVABadge.swift in Sources */,
F0000036 /* DietaryPills.swift in Sources */,
F0000037 /* NutritionSection.swift in Sources */,
F0000038 /* IngredientsSection.swift in Sources */,
F0000039 /* AlternativesScreen.swift in Sources */,
F0000040 /* FilterChips.swift in Sources */,
F0000041 /* AlternativeCard.swift in Sources */,
F0000042 /* SponsoredCard.swift in Sources */,
F0000043 /* FavoritesScreen.swift in Sources */,
F0000044 /* HistoryScreen.swift in Sources */,
F0000045 /* AccountScreen.swift in Sources */,
F0000046 /* LoginSheet.swift in Sources */,
F0000047 /* RegisterSheet.swift in Sources */,
F0000048 /* ProductCard.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
F7000001 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
F7000002 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
F7000003 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritFood/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Food";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
INFOPLIST_KEY_NSCameraUsageDescription = "We need camera access to scan barcodes on food products.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "We use your location to find nearby stores with healthier alternatives.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.food;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
F7000004 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = U83YL8VRF3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = PayfritFood/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Payfrit Food";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
INFOPLIST_KEY_NSCameraUsageDescription = "We need camera access to scan barcodes on food products.";
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "We use your location to find nearby stores with healthier alternatives.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.payfrit.food;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
F6000000 /* Build configuration list for PBXProject "PayfritFood" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F7000001 /* Debug */,
F7000002 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F6000001 /* Build configuration list for PBXNativeTarget "PayfritFood" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F7000003 /* Debug */,
F7000004 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = F5000001 /* Project object */;
}

View file

@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.294",
"green" : "0.698",
"red" : "0.133"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

55
PayfritFood/Info.plist Normal file
View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Payfrit Food</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>We need camera access to scan barcodes on food products.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to find nearby stores with healthier alternatives.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>LaunchBackground</string>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,85 @@
import Foundation
struct Alternative: Identifiable {
let id: Int
let product: Product
let price: Double?
let distance: Double? // miles from user
let deliveryUrl: String?
let pickupUrl: String?
let buyUrl: String?
let isSponsored: Bool
init(json: [String: Any]) {
id = JSON.parseInt(json["id"] ?? json["alternativeId"])
if let productData = json["product"] as? [String: Any] {
product = Product(json: productData)
} else {
// Product data might be at the root level
product = Product(json: json)
}
if let p = json["price"] as? Double, p > 0 {
price = p
} else if let p = json["Price"] as? Double, p > 0 {
price = p
} else {
price = nil
}
if let d = json["distance"] as? Double, d > 0 {
distance = d
} else if let d = json["Distance"] as? Double, d > 0 {
distance = d
} else {
distance = nil
}
deliveryUrl = JSON.parseOptionalString(json["deliveryUrl"] ?? json["DeliveryUrl"])
pickupUrl = JSON.parseOptionalString(json["pickupUrl"] ?? json["PickupUrl"])
buyUrl = JSON.parseOptionalString(json["buyUrl"] ?? json["BuyUrl"])
isSponsored = JSON.parseBool(json["isSponsored"] ?? json["IsSponsored"] ?? json["sponsored"])
}
init(
id: Int,
product: Product,
price: Double? = nil,
distance: Double? = nil,
deliveryUrl: String? = nil,
pickupUrl: String? = nil,
buyUrl: String? = nil,
isSponsored: Bool = false
) {
self.id = id
self.product = product
self.price = price
self.distance = distance
self.deliveryUrl = deliveryUrl
self.pickupUrl = pickupUrl
self.buyUrl = buyUrl
self.isSponsored = isSponsored
}
// MARK: - Computed Properties
var hasDelivery: Bool { deliveryUrl != nil }
var hasPickup: Bool { pickupUrl != nil }
var hasBuyLink: Bool { buyUrl != nil }
var formattedPrice: String? {
guard let price = price else { return nil }
return String(format: "$%.2f", price)
}
var formattedDistance: String? {
guard let distance = distance else { return nil }
if distance < 0.1 {
return "Nearby"
} else if distance < 1 {
return String(format: "%.1f mi", distance)
} else {
return String(format: "%.0f mi", distance)
}
}
}

View file

@ -0,0 +1,174 @@
import Foundation
struct Product: Identifiable, Hashable {
static func == (lhs: Product, rhs: Product) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
let id: Int
let barcode: String
let name: String
let brand: String
let imageUrl: String?
let score: Int // 0-100 weighted index
let novaGroup: Int // 1-4
let servingSize: String
let calories: Double
let fat: Double
let saturatedFat: Double
let carbs: Double
let sugar: Double
let fiber: Double
let protein: Double
let sodium: Double
let ingredients: String
let dietaryTags: [String] // ["vegan", "gluten-free", etc.]
init(json: [String: Any]) {
// Handle nested structures from API
let rating = json["Rating"] as? [String: Any] ?? [:]
let nutrition = json["Nutrition"] as? [String: Any] ?? [:]
let dietary = json["Dietary"] as? [String: Any] ?? [:]
id = JSON.parseInt(json["id"] ?? json["ID"] ?? json["productId"])
barcode = JSON.parseString(json["barcode"] ?? json["Barcode"] ?? json["upc"])
name = JSON.parseString(json["name"] ?? json["Name"] ?? json["productName"])
brand = JSON.parseString(json["brand"] ?? json["Brand"] ?? json["brandName"])
imageUrl = JSON.parseOptionalString(json["imageUrl"] ?? json["ImageURL"] ?? json["image"])
// Score from Rating.OverallScore or top-level
score = JSON.parseInt(rating["OverallScore"] ?? json["score"] ?? json["Score"])
novaGroup = JSON.parseInt(json["novaGroup"] ?? json["NovaGroup"] ?? json["nova"])
// Nutrition from nested object or top-level
servingSize = JSON.parseString(nutrition["ServingSize"] ?? json["servingSize"] ?? json["ServingSize"])
calories = JSON.parseDouble(nutrition["Calories"] ?? json["calories"] ?? json["Calories"])
fat = JSON.parseDouble(nutrition["Fat"] ?? json["fat"] ?? json["Fat"])
saturatedFat = JSON.parseDouble(nutrition["SaturatedFat"] ?? json["saturatedFat"] ?? json["SaturatedFat"])
carbs = JSON.parseDouble(nutrition["Carbohydrates"] ?? json["carbs"] ?? json["Carbs"])
sugar = JSON.parseDouble(nutrition["Sugars"] ?? json["sugar"] ?? json["Sugar"])
fiber = JSON.parseDouble(nutrition["Fiber"] ?? json["fiber"] ?? json["Fiber"])
protein = JSON.parseDouble(nutrition["Protein"] ?? json["protein"] ?? json["Protein"])
sodium = JSON.parseDouble(nutrition["Sodium"] ?? json["sodium"] ?? json["Sodium"])
ingredients = JSON.parseString(json["ingredients"] ?? json["Ingredients"])
// Build dietary tags from Dietary object or use existing array
var tags: [String] = []
if JSON.parseBool(dietary["IsVegan"]) { tags.append("Vegan") }
if JSON.parseBool(dietary["IsVegetarian"]) { tags.append("Vegetarian") }
if JSON.parseBool(dietary["IsGlutenFree"]) { tags.append("Gluten-Free") }
if JSON.parseBool(dietary["IsDairyFree"]) { tags.append("Dairy-Free") }
if JSON.parseBool(dietary["IsNutFree"]) { tags.append("Nut-Free") }
if JSON.parseBool(dietary["IsOrganic"]) { tags.append("Organic") }
if tags.isEmpty {
dietaryTags = JSON.parseStringArray(json["dietaryTags"] ?? json["DietaryTags"] ?? json["tags"])
} else {
dietaryTags = tags
}
}
init(
id: Int,
barcode: String,
name: String,
brand: String,
imageUrl: String? = nil,
score: Int,
novaGroup: Int,
servingSize: String,
calories: Double,
fat: Double,
saturatedFat: Double,
carbs: Double,
sugar: Double,
fiber: Double,
protein: Double,
sodium: Double,
ingredients: String,
dietaryTags: [String]
) {
self.id = id
self.barcode = barcode
self.name = name
self.brand = brand
self.imageUrl = imageUrl
self.score = score
self.novaGroup = novaGroup
self.servingSize = servingSize
self.calories = calories
self.fat = fat
self.saturatedFat = saturatedFat
self.carbs = carbs
self.sugar = sugar
self.fiber = fiber
self.protein = protein
self.sodium = sodium
self.ingredients = ingredients
self.dietaryTags = dietaryTags
}
// MARK: - Computed Properties
var imageURL: URL? {
guard let imageUrl = imageUrl else { return nil }
return URL(string: imageUrl)
}
var scoreColor: ScoreColor {
switch score {
case 80...100: return .excellent
case 50..<80: return .good
case 30..<50: return .fair
default: return .poor
}
}
var novaColor: NovaColor {
switch novaGroup {
case 1: return .nova1
case 2: return .nova2
case 3: return .nova3
default: return .nova4
}
}
enum ScoreColor {
case excellent, good, fair, poor
var color: String {
switch self {
case .excellent: return "scoreGreen"
case .good: return "scoreYellow"
case .fair: return "scoreOrange"
case .poor: return "scoreRed"
}
}
}
enum NovaColor {
case nova1, nova2, nova3, nova4
var color: String {
switch self {
case .nova1: return "novaGreen"
case .nova2: return "novaYellow"
case .nova3: return "novaOrange"
case .nova4: return "novaRed"
}
}
var label: String {
switch self {
case .nova1: return "Unprocessed"
case .nova2: return "Processed ingredients"
case .nova3: return "Processed"
case .nova4: return "Ultra-processed"
}
}
}
}

View file

@ -0,0 +1,38 @@
import Foundation
struct ScanHistoryItem: Identifiable {
let id: Int
let product: Product
let scannedAt: Date
init(json: [String: Any]) {
id = JSON.parseInt(json["id"] ?? json["historyId"])
if let productData = json["product"] as? [String: Any] {
product = Product(json: productData)
} else {
product = Product(json: json)
}
if let dateString = json["scannedAt"] as? String ?? json["ScannedAt"] as? String {
let formatter = ISO8601DateFormatter()
scannedAt = formatter.date(from: dateString) ?? Date()
} else if let timestamp = json["scannedAt"] as? Double ?? json["ScannedAt"] as? Double {
scannedAt = Date(timeIntervalSince1970: timestamp)
} else {
scannedAt = Date()
}
}
init(id: Int, product: Product, scannedAt: Date = Date()) {
self.id = id
self.product = product
self.scannedAt = scannedAt
}
var formattedDate: String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return formatter.localizedString(for: scannedAt, relativeTo: Date())
}
}

View file

@ -0,0 +1,41 @@
import Foundation
struct UserProfile {
let id: Int
let email: String
let name: String
let zipCode: String
let isPremium: Bool
let createdAt: Date?
init(json: [String: Any]) {
id = JSON.parseInt(json["id"] ?? json["userId"] ?? json["UserID"])
email = JSON.parseString(json["email"] ?? json["Email"])
name = JSON.parseString(json["name"] ?? json["Name"])
zipCode = JSON.parseString(json["zipCode"] ?? json["ZipCode"] ?? json["zip"])
isPremium = JSON.parseBool(json["isPremium"] ?? json["IsPremium"] ?? json["premium"])
if let dateString = json["createdAt"] as? String ?? json["CreatedAt"] as? String {
let formatter = ISO8601DateFormatter()
createdAt = formatter.date(from: dateString)
} else {
createdAt = nil
}
}
init(
id: Int,
email: String,
name: String,
zipCode: String,
isPremium: Bool = false,
createdAt: Date? = nil
) {
self.id = id
self.email = email
self.name = name
self.zipCode = zipCode
self.isPremium = isPremium
self.createdAt = createdAt
}
}

View file

@ -0,0 +1,14 @@
import SwiftUI
@main
struct PayfritFoodApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(appState)
.preferredColorScheme(.dark)
}
}
}

View file

@ -0,0 +1,281 @@
import Foundation
// MARK: - API Error
enum APIError: LocalizedError {
case invalidResponse
case httpError(statusCode: Int, message: String)
case serverError(String)
case decodingError(String)
case unauthorized
case noData
case invalidURL
var errorDescription: String? {
switch self {
case .invalidResponse: return "Invalid response from server"
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
case .serverError(let msg): return msg
case .decodingError(let msg): return "Failed to decode: \(msg)"
case .unauthorized: return "Please log in to continue"
case .noData: return "No data received"
case .invalidURL: return "Invalid URL"
}
}
}
// MARK: - API Service
actor APIService {
static let shared = APIService()
static let baseURL = "https://food.payfrit.com/api"
private let session: URLSession
private var userToken: String?
init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
self.session = URLSession(configuration: config)
}
// MARK: - Token Management
func setToken(_ token: String) {
self.userToken = token
}
func clearToken() {
self.userToken = nil
}
// MARK: - HTTP Methods
private func request(_ endpoint: String, method: String = "GET", body: [String: Any]? = nil, includeAuth: Bool = true) async throws -> [String: Any] {
guard let url = URL(string: "\(Self.baseURL)/\(endpoint)") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if includeAuth, let token = userToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
if httpResponse.statusCode == 401 {
throw APIError.unauthorized
}
guard (200...299).contains(httpResponse.statusCode) else {
let message = String(data: data, encoding: .utf8) ?? "Unknown error"
throw APIError.httpError(statusCode: httpResponse.statusCode, message: message)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw APIError.decodingError("Invalid JSON response")
}
// Check for API-level errors
if let ok = json["ok"] as? Bool, !ok {
let message = json["error"] as? String ?? json["message"] as? String ?? "Unknown error"
throw APIError.serverError(message)
}
return json
}
private func get(_ endpoint: String, includeAuth: Bool = true) async throws -> [String: Any] {
return try await request(endpoint, method: "GET", includeAuth: includeAuth)
}
private func post(_ endpoint: String, body: [String: Any], includeAuth: Bool = true) async throws -> [String: Any] {
return try await request(endpoint, method: "POST", body: body, includeAuth: includeAuth)
}
private func delete(_ endpoint: String) async throws -> [String: Any] {
return try await request(endpoint, method: "DELETE")
}
// MARK: - Auth Endpoints
func login(email: String, password: String) async throws -> (UserProfile, String) {
let json = try await post("user/login.php", body: ["email": email, "password": password], includeAuth: false)
guard let token = json["token"] as? String,
let userData = json["user"] as? [String: Any] else {
throw APIError.decodingError("Missing token or user data")
}
let profile = UserProfile(json: userData)
return (profile, token)
}
func register(email: String, password: String, name: String, zipCode: String) async throws -> (UserProfile, String) {
let body: [String: Any] = [
"email": email,
"password": password,
"name": name,
"zipCode": zipCode
]
let json = try await post("user/register.php", body: body, includeAuth: false)
guard let token = json["token"] as? String,
let userData = json["user"] as? [String: Any] else {
throw APIError.decodingError("Missing token or user data")
}
let profile = UserProfile(json: userData)
return (profile, token)
}
func logout() async throws {
_ = try await post("user/account.php", body: ["action": "logout"])
}
func deleteAccount() async throws {
_ = try await post("user/account.php", body: ["action": "delete"])
}
// MARK: - User Endpoints
func getProfile() async throws -> UserProfile {
let json = try await get("user/account.php")
guard let userData = json["user"] as? [String: Any] else {
throw APIError.decodingError("Missing user data")
}
return UserProfile(json: userData)
}
func exportData() async throws -> URL {
let json = try await post("user/account.php", body: ["action": "export"])
guard let urlString = json["downloadUrl"] as? String,
let url = URL(string: urlString) else {
throw APIError.decodingError("Missing download URL")
}
return url
}
// MARK: - Product Endpoints
func lookupProduct(barcode: String) async throws -> Product {
let json = try await get("scan.php?barcode=\(barcode)", includeAuth: false)
// API returns product data at root level or nested under "product"
let productData = (json["product"] as? [String: Any]) ?? json
return Product(json: productData)
}
func getProduct(id: Int) async throws -> Product {
let json = try await get("scan.php?id=\(id)", includeAuth: false)
// API returns product data at root level or nested under "product"
let productData = (json["product"] as? [String: Any]) ?? json
return Product(json: productData)
}
func getAlternatives(productId: Int, sort: String? = nil, filters: [String]? = nil) async throws -> [Alternative] {
var endpoint = "alternatives.php?productId=\(productId)"
if let sort = sort {
endpoint += "&sort=\(sort)"
}
if let filters = filters, !filters.isEmpty {
endpoint += "&filters=\(filters.joined(separator: ","))"
}
let json = try await get(endpoint, includeAuth: false)
guard let alternativesData = json["alternatives"] as? [[String: Any]] else {
return []
}
return alternativesData.map { Alternative(json: $0) }
}
// MARK: - Favorites Endpoints
func getFavorites() async throws -> [Product] {
let json = try await get("user/favorites.php")
guard let productsData = json["products"] as? [[String: Any]] else {
return []
}
return productsData.map { Product(json: $0) }
}
func addFavorite(productId: Int) async throws {
_ = try await post("user/favorites.php", body: ["action": "add", "productId": productId])
}
func removeFavorite(productId: Int) async throws {
_ = try await post("user/favorites.php", body: ["action": "remove", "productId": productId])
}
// MARK: - History Endpoints
func getHistory() async throws -> [ScanHistoryItem] {
let json = try await get("user/scans.php")
guard let itemsData = json["items"] as? [[String: Any]] else {
return []
}
return itemsData.map { ScanHistoryItem(json: $0) }
}
func addToHistory(productId: Int) async throws {
_ = try await post("user/scans.php", body: ["productId": productId])
}
}
// MARK: - JSON Helpers
enum JSON {
static func parseInt(_ value: Any?) -> Int {
if let i = value as? Int { return i }
if let s = value as? String, let i = Int(s) { return i }
if let d = value as? Double { return Int(d) }
return 0
}
static func parseDouble(_ value: Any?) -> Double {
if let d = value as? Double { return d }
if let i = value as? Int { return Double(i) }
if let s = value as? String, let d = Double(s) { return d }
return 0
}
static func parseString(_ value: Any?) -> String {
if let s = value as? String { return s }
if let i = value as? Int { return String(i) }
if let d = value as? Double { return String(d) }
return ""
}
static func parseBool(_ value: Any?) -> Bool {
if let b = value as? Bool { return b }
if let i = value as? Int { return i != 0 }
if let s = value as? String { return s.lowercased() == "true" || s == "1" }
return false
}
static func parseOptionalString(_ value: Any?) -> String? {
if let s = value as? String, !s.isEmpty { return s }
return nil
}
static func parseStringArray(_ value: Any?) -> [String] {
if let arr = value as? [String] { return arr }
if let s = value as? String { return s.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) } }
return []
}
}

View file

@ -0,0 +1,86 @@
import Foundation
import Security
struct AuthCredentials {
let userId: Int
let token: String
}
actor AuthStorage {
static let shared = AuthStorage()
private let serviceName = "com.payfrit.food"
private let tokenKey = "userToken"
private let userIdKey = "userId"
// MARK: - Public Interface
func saveCredentials(_ credentials: AuthCredentials) {
saveToKeychain(key: tokenKey, value: credentials.token)
saveToKeychain(key: userIdKey, value: String(credentials.userId))
UserDefaults.standard.set(credentials.userId, forKey: userIdKey)
}
func loadCredentials() -> AuthCredentials? {
guard let token = loadFromKeychain(key: tokenKey),
let userIdString = loadFromKeychain(key: userIdKey),
let userId = Int(userIdString) else {
return nil
}
return AuthCredentials(userId: userId, token: token)
}
func clearAll() {
deleteFromKeychain(key: tokenKey)
deleteFromKeychain(key: userIdKey)
UserDefaults.standard.removeObject(forKey: userIdKey)
}
// MARK: - Keychain Operations
private func saveToKeychain(key: String, value: String) {
let data = value.data(using: .utf8)!
// Delete existing item first
deleteFromKeychain(key: key)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
SecItemAdd(query as CFDictionary, nil)
}
private func loadFromKeychain(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8) else {
return nil
}
return value
}
private func deleteFromKeychain(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}

View file

@ -0,0 +1,144 @@
import AVFoundation
import SwiftUI
class BarcodeScanner: NSObject, ObservableObject {
@Published var scannedCode: String?
@Published var isScanning = false
@Published var error: String?
private var captureSession: AVCaptureSession?
private let metadataOutput = AVCaptureMetadataOutput()
// Supported barcode types for food products
private let supportedTypes: [AVMetadataObject.ObjectType] = [
.ean8,
.ean13,
.upce,
.code128,
.code39,
.code93,
.itf14
]
override init() {
super.init()
}
// MARK: - Public Interface
func checkPermission() async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .video)
default:
return false
}
}
func setupSession() {
guard captureSession == nil else { return }
let session = AVCaptureSession()
session.sessionPreset = .high
guard let videoDevice = AVCaptureDevice.default(for: .video),
let videoInput = try? AVCaptureDeviceInput(device: videoDevice) else {
error = "Camera not available"
return
}
if session.canAddInput(videoInput) {
session.addInput(videoInput)
}
if session.canAddOutput(metadataOutput) {
session.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = supportedTypes.filter {
metadataOutput.availableMetadataObjectTypes.contains($0)
}
}
captureSession = session
}
func startScanning() {
guard let session = captureSession else {
setupSession()
startScanning()
return
}
scannedCode = nil
error = nil
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
session.startRunning()
DispatchQueue.main.async {
self?.isScanning = true
}
}
}
func stopScanning() {
captureSession?.stopRunning()
isScanning = false
}
func reset() {
scannedCode = nil
error = nil
}
var previewLayer: AVCaptureVideoPreviewLayer? {
guard let session = captureSession else { return nil }
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
return layer
}
}
// MARK: - AVCaptureMetadataOutputObjectsDelegate
extension BarcodeScanner: AVCaptureMetadataOutputObjectsDelegate {
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
let code = metadataObject.stringValue,
scannedCode == nil else { return }
// Haptic feedback
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
scannedCode = code
stopScanning()
}
}
// MARK: - Camera Preview View
struct CameraPreviewView: UIViewRepresentable {
let scanner: BarcodeScanner
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .black
if let previewLayer = scanner.previewLayer {
previewLayer.frame = view.bounds
view.layer.addSublayer(previewLayer)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
DispatchQueue.main.async {
if let sublayer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
sublayer.frame = uiView.bounds
} else if let previewLayer = scanner.previewLayer {
previewLayer.frame = uiView.bounds
uiView.layer.addSublayer(previewLayer)
}
}
}
}

View file

@ -0,0 +1,51 @@
import CoreLocation
import SwiftUI
class LocationService: NSObject, ObservableObject {
@Published var currentLocation: CLLocation?
@Published var authorizationStatus: CLAuthorizationStatus = .notDetermined
@Published var error: String?
private let locationManager = CLLocationManager()
override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
authorizationStatus = locationManager.authorizationStatus
}
func requestPermission() {
locationManager.requestWhenInUseAuthorization()
}
func requestLocation() {
guard authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways else {
requestPermission()
return
}
locationManager.requestLocation()
}
func hasPermission() -> Bool {
return authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways
}
}
// MARK: - CLLocationManagerDelegate
extension LocationService: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
currentLocation = locations.last
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
self.error = error.localizedDescription
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
authorizationStatus = manager.authorizationStatus
if hasPermission() {
requestLocation()
}
}
}

View file

@ -0,0 +1,119 @@
import SwiftUI
@MainActor
class AppState: ObservableObject {
// MARK: - Authentication State
@Published var isAuthenticated = false
@Published var userId: Int?
@Published var userToken: String?
@Published var userProfile: UserProfile?
@Published var isPremium = false
// MARK: - Current Product State
@Published var currentProduct: Product?
@Published var currentAlternatives: [Alternative] = []
// MARK: - Navigation State
@Published var selectedTab: Tab = .scan
@Published var showLoginSheet = false
@Published var showRegisterSheet = false
// MARK: - Loading State
@Published var isLoading = false
@Published var errorMessage: String?
enum Tab: Int, CaseIterable {
case scan = 0
case favorites = 1
case history = 2
case account = 3
var title: String {
switch self {
case .scan: return "Scan"
case .favorites: return "Favorites"
case .history: return "History"
case .account: return "Account"
}
}
var icon: String {
switch self {
case .scan: return "barcode.viewfinder"
case .favorites: return "heart.fill"
case .history: return "clock.fill"
case .account: return "person.fill"
}
}
}
// MARK: - Initialization
init() {
Task {
await loadSavedAuth()
}
}
// MARK: - Authentication
func loadSavedAuth() async {
guard let credentials = await AuthStorage.shared.loadCredentials() else { return }
self.userId = credentials.userId
self.userToken = credentials.token
await APIService.shared.setToken(credentials.token)
// Try to fetch profile
do {
let profile = try await APIService.shared.getProfile()
self.userProfile = profile
self.isPremium = profile.isPremium
self.isAuthenticated = true
} catch {
// Token may be expired
await clearAuth()
}
}
func setAuth(userId: Int, token: String, profile: UserProfile) async {
self.userId = userId
self.userToken = token
self.userProfile = profile
self.isPremium = profile.isPremium
self.isAuthenticated = true
await APIService.shared.setToken(token)
await AuthStorage.shared.saveCredentials(AuthCredentials(userId: userId, token: token))
}
func clearAuth() async {
self.userId = nil
self.userToken = nil
self.userProfile = nil
self.isPremium = false
self.isAuthenticated = false
await APIService.shared.clearToken()
await AuthStorage.shared.clearAll()
}
// MARK: - Product Actions
func setCurrentProduct(_ product: Product) {
self.currentProduct = product
self.currentAlternatives = []
}
func clearCurrentProduct() {
self.currentProduct = nil
self.currentAlternatives = []
}
// MARK: - Error Handling
func showError(_ message: String) {
self.errorMessage = message
Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
self.errorMessage = nil
}
}
}

View file

@ -0,0 +1,234 @@
import SwiftUI
struct AccountScreen: View {
@EnvironmentObject var appState: AppState
@State private var showLogoutConfirm = false
@State private var showDeleteConfirm = false
@State private var isExporting = false
var body: some View {
NavigationStack {
Group {
if !appState.isAuthenticated {
// Not logged in
VStack(spacing: 20) {
Spacer()
Image(systemName: "person.crop.circle")
.font(.system(size: 80))
.foregroundColor(.gray)
Text("Your Account")
.font(.title2.bold())
Text("Log in to access your profile, export your data, and manage your account.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
appState.showLoginSheet = true
} label: {
Text("Log In")
.font(.headline)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.cornerRadius(12)
}
.padding(.horizontal, 40)
Button {
appState.showRegisterSheet = true
} label: {
Text("Create Account")
.font(.headline)
.foregroundColor(.green)
}
Spacer()
}
} else {
List {
// Profile Section
Section {
if let profile = appState.userProfile {
HStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.green.opacity(0.2))
.frame(width: 60, height: 60)
Text(profile.name.prefix(1).uppercased())
.font(.title.bold())
.foregroundColor(.green)
}
VStack(alignment: .leading, spacing: 4) {
Text(profile.name)
.font(.headline)
Text(profile.email)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
// Premium Section
Section {
if appState.isPremium {
HStack {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
Text("Premium Member")
.font(.headline)
Spacer()
Text("Active")
.foregroundColor(.green)
}
} else {
HStack {
Image(systemName: "star")
.foregroundColor(.yellow)
VStack(alignment: .leading) {
Text("Go Premium")
.font(.headline)
Text("Remove sponsored content")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
}
}
// Data Section
Section("Your Data") {
Button {
exportData()
} label: {
HStack {
Image(systemName: "square.and.arrow.up")
Text("Export Data")
Spacer()
if isExporting {
ProgressView()
}
}
}
.disabled(isExporting)
}
// Account Actions
Section {
Button {
showLogoutConfirm = true
} label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Log Out")
}
}
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete Account")
}
}
}
// App Info
Section("About") {
HStack {
Text("Version")
Spacer()
Text("1.0.0")
.foregroundColor(.secondary)
}
Link(destination: URL(string: "https://food.payfrit.com/privacy")!) {
HStack {
Text("Privacy Policy")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
Link(destination: URL(string: "https://food.payfrit.com/terms")!) {
HStack {
Text("Terms of Service")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
}
}
.navigationTitle("Account")
.alert("Log Out", isPresented: $showLogoutConfirm) {
Button("Cancel", role: .cancel) {}
Button("Log Out", role: .destructive) {
logout()
}
} message: {
Text("Are you sure you want to log out?")
}
.alert("Delete Account", isPresented: $showDeleteConfirm) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteAccount()
}
} message: {
Text("This action cannot be undone. All your data will be permanently deleted.")
}
}
}
private func logout() {
Task {
try? await APIService.shared.logout()
await appState.clearAuth()
}
}
private func deleteAccount() {
Task {
do {
try await APIService.shared.deleteAccount()
await appState.clearAuth()
} catch {
appState.showError(error.localizedDescription)
}
}
}
private func exportData() {
isExporting = true
Task {
do {
let url = try await APIService.shared.exportData()
await MainActor.run {
isExporting = false
UIApplication.shared.open(url)
}
} catch {
await MainActor.run {
isExporting = false
appState.showError(error.localizedDescription)
}
}
}
}
}

View file

@ -0,0 +1,118 @@
import SwiftUI
struct LoginSheet: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var email = ""
@State private var password = ""
@State private var isLoading = false
@State private var error: String?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Logo
Image(systemName: "leaf.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
.padding(.top, 40)
Text("Welcome Back")
.font(.title.bold())
Text("Log in to save favorites and view your scan history.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Form
VStack(spacing: 16) {
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $password)
.textContentType(.password)
.textFieldStyle(.roundedBorder)
}
.padding(.horizontal, 24)
if let error = error {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
// Login Button
Button {
login()
} label: {
if isLoading {
ProgressView()
.tint(.black)
} else {
Text("Log In")
.font(.headline)
}
}
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(isValid ? Color.green : Color.gray)
.cornerRadius(12)
.disabled(!isValid || isLoading)
.padding(.horizontal, 24)
// Register Link
HStack {
Text("Don't have an account?")
.foregroundColor(.secondary)
Button("Sign Up") {
dismiss()
appState.showRegisterSheet = true
}
.foregroundColor(.green)
}
.font(.subheadline)
Spacer()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private var isValid: Bool {
!email.isEmpty && email.contains("@") && !password.isEmpty
}
private func login() {
isLoading = true
error = nil
Task {
do {
let (profile, token) = try await APIService.shared.login(email: email, password: password)
await appState.setAuth(userId: profile.id, token: token, profile: profile)
await MainActor.run {
dismiss()
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
isLoading = false
}
}
}
}
}

View file

@ -0,0 +1,140 @@
import SwiftUI
struct RegisterSheet: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var email = ""
@State private var password = ""
@State private var zipCode = ""
@State private var isLoading = false
@State private var error: String?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Logo
Image(systemName: "leaf.circle.fill")
.font(.system(size: 60))
.foregroundColor(.green)
.padding(.top, 40)
Text("Create Account")
.font(.title.bold())
Text("Sign up to save favorites, track your scan history, and get personalized recommendations.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
// Form
VStack(spacing: 16) {
TextField("Name", text: $name)
.textContentType(.name)
.textFieldStyle(.roundedBorder)
TextField("Email", text: $email)
.keyboardType(.emailAddress)
.textContentType(.emailAddress)
.autocapitalization(.none)
.textFieldStyle(.roundedBorder)
SecureField("Password", text: $password)
.textContentType(.newPassword)
.textFieldStyle(.roundedBorder)
TextField("ZIP Code", text: $zipCode)
.keyboardType(.numberPad)
.textContentType(.postalCode)
.textFieldStyle(.roundedBorder)
Text("We use your ZIP code to find stores near you.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 24)
if let error = error {
Text(error)
.font(.caption)
.foregroundColor(.red)
}
// Register Button
Button {
register()
} label: {
if isLoading {
ProgressView()
.tint(.black)
} else {
Text("Create Account")
.font(.headline)
}
}
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(isValid ? Color.green : Color.gray)
.cornerRadius(12)
.disabled(!isValid || isLoading)
.padding(.horizontal, 24)
// Login Link
HStack {
Text("Already have an account?")
.foregroundColor(.secondary)
Button("Log In") {
dismiss()
appState.showLoginSheet = true
}
.foregroundColor(.green)
}
.font(.subheadline)
Spacer(minLength: 40)
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
}
}
private var isValid: Bool {
!name.isEmpty && !email.isEmpty && email.contains("@") && password.count >= 6 && zipCode.count >= 5
}
private func register() {
isLoading = true
error = nil
Task {
do {
let (profile, token) = try await APIService.shared.register(
email: email,
password: password,
name: name,
zipCode: zipCode
)
await appState.setAuth(userId: profile.id, token: token, profile: profile)
await MainActor.run {
dismiss()
}
} catch {
await MainActor.run {
self.error = error.localizedDescription
isLoading = false
}
}
}
}
}

View file

@ -0,0 +1,114 @@
import SwiftUI
struct AlternativeCard: View {
let alternative: Alternative
var body: some View {
HStack(spacing: 12) {
// Product Image
if let imageURL = alternative.product.imageURL {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
imagePlaceholder
default:
ProgressView()
}
}
.frame(width: 80, height: 80)
.background(Color(.systemGray6))
.cornerRadius(12)
.clipped()
} else {
imagePlaceholder
.frame(width: 80, height: 80)
}
// Product Info
VStack(alignment: .leading, spacing: 6) {
Text(alternative.product.brand)
.font(.caption)
.foregroundColor(.secondary)
Text(alternative.product.name)
.font(.subheadline.bold())
.lineLimit(2)
HStack(spacing: 12) {
// Score
HStack(spacing: 4) {
Circle()
.fill(scoreColor(alternative.product.score))
.frame(width: 10, height: 10)
Text("\(alternative.product.score)")
.font(.caption.bold())
}
// NOVA
Text("NOVA \(alternative.product.novaGroup)")
.font(.caption)
.foregroundColor(.secondary)
// Price
if let price = alternative.formattedPrice {
Text(price)
.font(.caption.bold())
.foregroundColor(.green)
}
// Distance
if let distance = alternative.formattedDistance {
Text(distance)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Spacer()
// Score Ring (mini)
ZStack {
Circle()
.stroke(lineWidth: 4)
.opacity(0.2)
.foregroundColor(scoreColor(alternative.product.score))
Circle()
.trim(from: 0, to: Double(alternative.product.score) / 100.0)
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round))
.foregroundColor(scoreColor(alternative.product.score))
.rotationEffect(.degrees(-90))
Text("\(alternative.product.score)")
.font(.caption2.bold())
}
.frame(width: 40, height: 40)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(16)
}
private var imagePlaceholder: some View {
ZStack {
Color(.systemGray5)
Image(systemName: "carrot.fill")
.foregroundColor(.gray)
}
.cornerRadius(12)
}
private func scoreColor(_ score: Int) -> Color {
switch score {
case 80...100: return .green
case 50..<80: return .yellow
case 30..<50: return .orange
default: return .red
}
}
}

View file

@ -0,0 +1,145 @@
import SwiftUI
struct AlternativesScreen: View {
@EnvironmentObject var appState: AppState
let product: Product
@State private var alternatives: [Alternative] = []
@State private var isLoading = true
@State private var selectedSort: SortOption = .rating
@State private var activeFilters: Set<FilterOption> = []
enum SortOption: String, CaseIterable {
case rating = "Rating"
case price = "Price"
case distance = "Distance"
case processing = "Processing Level"
}
enum FilterOption: String, CaseIterable {
case vegan = "Vegan"
case vegetarian = "Vegetarian"
case glutenFree = "Gluten-Free"
case dairyFree = "Dairy-Free"
case nutFree = "Nut-Free"
case organic = "Organic"
case delivery = "Delivery"
case pickup = "Pickup"
case buyOnline = "Buy Online"
}
var body: some View {
VStack(spacing: 0) {
// Sort and Filter Controls
VStack(spacing: 12) {
// Sort Picker
HStack {
Text("Sort by:")
.font(.subheadline)
.foregroundColor(.secondary)
Picker("Sort", selection: $selectedSort) {
ForEach(SortOption.allCases, id: \.self) { option in
Text(option.rawValue).tag(option)
}
}
.pickerStyle(.menu)
Spacer()
}
.padding(.horizontal)
// Filter Chips
FilterChips(activeFilters: $activeFilters)
}
.padding(.vertical, 12)
.background(Color(.secondarySystemBackground))
// Alternatives List
if isLoading {
Spacer()
ProgressView("Finding alternatives...")
Spacer()
} else if filteredAlternatives.isEmpty {
Spacer()
VStack(spacing: 16) {
Image(systemName: "magnifyingglass")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No alternatives found")
.font(.headline)
Text("Try adjusting your filters")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(filteredAlternatives) { alternative in
if alternative.isSponsored && !appState.isPremium {
SponsoredCard(alternative: alternative)
} else if !alternative.isSponsored {
AlternativeCard(alternative: alternative)
}
}
}
.padding()
}
}
}
.navigationTitle("Alternatives")
.navigationBarTitleDisplayMode(.inline)
.task {
await loadAlternatives()
}
.onChange(of: selectedSort) { _ in
Task { await loadAlternatives() }
}
}
private var filteredAlternatives: [Alternative] {
alternatives.filter { alternative in
if activeFilters.isEmpty { return true }
for filter in activeFilters {
switch filter {
case .vegan:
if !alternative.product.dietaryTags.contains(where: { $0.lowercased() == "vegan" }) { return false }
case .vegetarian:
if !alternative.product.dietaryTags.contains(where: { $0.lowercased() == "vegetarian" }) { return false }
case .glutenFree:
if !alternative.product.dietaryTags.contains(where: { $0.lowercased().contains("gluten") }) { return false }
case .dairyFree:
if !alternative.product.dietaryTags.contains(where: { $0.lowercased().contains("dairy") }) { return false }
case .nutFree:
if !alternative.product.dietaryTags.contains(where: { $0.lowercased().contains("nut") }) { return false }
case .organic:
if !alternative.product.dietaryTags.contains(where: { $0.lowercased() == "organic" }) { return false }
case .delivery:
if !alternative.hasDelivery { return false }
case .pickup:
if !alternative.hasPickup { return false }
case .buyOnline:
if !alternative.hasBuyLink { return false }
}
}
return true
}
}
private func loadAlternatives() async {
isLoading = true
do {
let sortParam = selectedSort.rawValue.lowercased().replacingOccurrences(of: " ", with: "_")
let result = try await APIService.shared.getAlternatives(productId: product.id, sort: sortParam)
await MainActor.run {
alternatives = result
isLoading = false
}
} catch {
await MainActor.run {
isLoading = false
appState.showError(error.localizedDescription)
}
}
}
}

View file

@ -0,0 +1,43 @@
import SwiftUI
struct FilterChips: View {
@Binding var activeFilters: Set<AlternativesScreen.FilterOption>
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(AlternativesScreen.FilterOption.allCases, id: \.self) { filter in
FilterChip(
label: filter.rawValue,
isActive: activeFilters.contains(filter)
) {
if activeFilters.contains(filter) {
activeFilters.remove(filter)
} else {
activeFilters.insert(filter)
}
}
}
}
.padding(.horizontal)
}
}
}
struct FilterChip: View {
let label: String
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.bold())
.foregroundColor(isActive ? .white : .primary)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isActive ? Color.green : Color(.systemGray5))
.cornerRadius(20)
}
}
}

View file

@ -0,0 +1,142 @@
import SwiftUI
struct SponsoredCard: View {
let alternative: Alternative
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Sponsored label
HStack {
Text("Sponsored")
.font(.caption2.bold())
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color(.systemGray5))
.cornerRadius(4)
Spacer()
}
HStack(spacing: 12) {
// Product Image
if let imageURL = alternative.product.imageURL {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
imagePlaceholder
default:
ProgressView()
}
}
.frame(width: 80, height: 80)
.background(Color(.systemGray6))
.cornerRadius(12)
.clipped()
} else {
imagePlaceholder
.frame(width: 80, height: 80)
}
// Product Info
VStack(alignment: .leading, spacing: 6) {
Text(alternative.product.brand)
.font(.caption)
.foregroundColor(.secondary)
Text(alternative.product.name)
.font(.subheadline.bold())
.lineLimit(2)
HStack(spacing: 12) {
// Score
HStack(spacing: 4) {
Circle()
.fill(scoreColor(alternative.product.score))
.frame(width: 10, height: 10)
Text("\(alternative.product.score)")
.font(.caption.bold())
}
// Price
if let price = alternative.formattedPrice {
Text(price)
.font(.caption.bold())
.foregroundColor(.green)
}
}
}
Spacer()
}
// Action Buttons
HStack(spacing: 8) {
if let deliveryUrl = alternative.deliveryUrl, let url = URL(string: deliveryUrl) {
Link(destination: url) {
ActionButton(icon: "truck.box.fill", label: "Delivery")
}
}
if let pickupUrl = alternative.pickupUrl, let url = URL(string: pickupUrl) {
Link(destination: url) {
ActionButton(icon: "building.2.fill", label: "Pickup")
}
}
if let buyUrl = alternative.buyUrl, let url = URL(string: buyUrl) {
Link(destination: url) {
ActionButton(icon: "cart.fill", label: "Buy")
}
}
}
}
.padding()
.background(Color(.secondarySystemBackground))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.green.opacity(0.3), lineWidth: 1)
)
.cornerRadius(16)
}
private var imagePlaceholder: some View {
ZStack {
Color(.systemGray5)
Image(systemName: "carrot.fill")
.foregroundColor(.gray)
}
.cornerRadius(12)
}
private func scoreColor(_ score: Int) -> Color {
switch score {
case 80...100: return .green
case 50..<80: return .yellow
case 30..<50: return .orange
default: return .red
}
}
}
struct ActionButton: View {
let icon: String
let label: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
Text(label)
.font(.caption.bold())
}
.foregroundColor(.green)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.green.opacity(0.15))
.cornerRadius(8)
}
}

View file

@ -0,0 +1,113 @@
import SwiftUI
struct ProductCard: View {
let product: Product
var body: some View {
HStack(spacing: 12) {
// Product Image
if let imageURL = product.imageURL {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
imagePlaceholder
default:
ProgressView()
}
}
.frame(width: 70, height: 70)
.background(Color(.systemGray6))
.cornerRadius(12)
.clipped()
} else {
imagePlaceholder
.frame(width: 70, height: 70)
}
// Product Info
VStack(alignment: .leading, spacing: 6) {
Text(product.brand)
.font(.caption)
.foregroundColor(.secondary)
Text(product.name)
.font(.subheadline.bold())
.lineLimit(2)
HStack(spacing: 12) {
// Score
HStack(spacing: 4) {
Circle()
.fill(scoreColor)
.frame(width: 10, height: 10)
Text("\(product.score)")
.font(.caption.bold())
}
// NOVA
Text("NOVA \(product.novaGroup)")
.font(.caption)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(novaColor.opacity(0.2))
.foregroundColor(novaColor)
.cornerRadius(4)
}
}
Spacer()
// Score Ring (mini)
ZStack {
Circle()
.stroke(lineWidth: 4)
.opacity(0.2)
.foregroundColor(scoreColor)
Circle()
.trim(from: 0, to: Double(product.score) / 100.0)
.stroke(style: StrokeStyle(lineWidth: 4, lineCap: .round))
.foregroundColor(scoreColor)
.rotationEffect(.degrees(-90))
Text("\(product.score)")
.font(.caption2.bold())
}
.frame(width: 40, height: 40)
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(16)
}
private var imagePlaceholder: some View {
ZStack {
Color(.systemGray5)
Image(systemName: "carrot.fill")
.foregroundColor(.gray)
}
.cornerRadius(12)
}
private var scoreColor: Color {
switch product.score {
case 80...100: return .green
case 50..<80: return .yellow
case 30..<50: return .orange
default: return .red
}
}
private var novaColor: Color {
switch product.novaGroup {
case 1: return .green
case 2: return .yellow
case 3: return .orange
default: return .red
}
}
}

View file

@ -0,0 +1,114 @@
import SwiftUI
struct FavoritesScreen: View {
@EnvironmentObject var appState: AppState
@State private var favorites: [Product] = []
@State private var isLoading = true
@State private var selectedProduct: Product?
var body: some View {
NavigationStack {
Group {
if !appState.isAuthenticated {
// Not logged in
VStack(spacing: 20) {
Spacer()
Image(systemName: "heart.fill")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("Save Your Favorites")
.font(.title2.bold())
Text("Log in to save products you love and access them anytime.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
appState.showLoginSheet = true
} label: {
Text("Log In")
.font(.headline)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.cornerRadius(12)
}
.padding(.horizontal, 40)
Spacer()
}
} else if isLoading {
ProgressView("Loading favorites...")
} else if favorites.isEmpty {
VStack(spacing: 20) {
Spacer()
Image(systemName: "heart")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("No Favorites Yet")
.font(.title2.bold())
Text("Scan products and tap the heart icon to save them here.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(favorites) { product in
ProductCard(product: product)
.onTapGesture {
selectedProduct = product
}
}
}
.padding()
}
}
}
.navigationTitle("Favorites")
.navigationDestination(isPresented: Binding(
get: { selectedProduct != nil },
set: { if !$0 { selectedProduct = nil } }
)) {
if let product = selectedProduct {
ProductScreen(product: product)
.environmentObject(appState)
}
}
.task {
if appState.isAuthenticated {
await loadFavorites()
}
}
.refreshable {
await loadFavorites()
}
}
}
private func loadFavorites() async {
isLoading = true
do {
let result = try await APIService.shared.getFavorites()
await MainActor.run {
favorites = result
isLoading = false
}
} catch {
await MainActor.run {
isLoading = false
appState.showError(error.localizedDescription)
}
}
}
}

View file

@ -0,0 +1,191 @@
import SwiftUI
struct HistoryScreen: View {
@EnvironmentObject var appState: AppState
@State private var history: [ScanHistoryItem] = []
@State private var isLoading = true
@State private var selectedProduct: Product?
var body: some View {
NavigationStack {
Group {
if !appState.isAuthenticated {
// Not logged in
VStack(spacing: 20) {
Spacer()
Image(systemName: "clock.fill")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("Track Your Scans")
.font(.title2.bold())
Text("Log in to keep a history of all the products you've scanned.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
appState.showLoginSheet = true
} label: {
Text("Log In")
.font(.headline)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.cornerRadius(12)
}
.padding(.horizontal, 40)
Spacer()
}
} else if isLoading {
ProgressView("Loading history...")
} else if history.isEmpty {
VStack(spacing: 20) {
Spacer()
Image(systemName: "clock")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("No Scan History")
.font(.title2.bold())
Text("Products you scan will appear here.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
} else {
List {
ForEach(history) { item in
HistoryRow(item: item)
.onTapGesture {
selectedProduct = item.product
}
}
}
.listStyle(.plain)
}
}
.navigationTitle("History")
.navigationDestination(isPresented: Binding(
get: { selectedProduct != nil },
set: { if !$0 { selectedProduct = nil } }
)) {
if let product = selectedProduct {
ProductScreen(product: product)
.environmentObject(appState)
}
}
.task {
if appState.isAuthenticated {
await loadHistory()
}
}
.refreshable {
await loadHistory()
}
}
}
private func loadHistory() async {
isLoading = true
do {
let result = try await APIService.shared.getHistory()
await MainActor.run {
history = result
isLoading = false
}
} catch {
await MainActor.run {
isLoading = false
appState.showError(error.localizedDescription)
}
}
}
}
struct HistoryRow: View {
let item: ScanHistoryItem
var body: some View {
HStack(spacing: 12) {
// Product Image
if let imageURL = item.product.imageURL {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
imagePlaceholder
default:
ProgressView()
}
}
.frame(width: 50, height: 50)
.background(Color(.systemGray6))
.cornerRadius(8)
.clipped()
} else {
imagePlaceholder
.frame(width: 50, height: 50)
}
VStack(alignment: .leading, spacing: 4) {
Text(item.product.name)
.font(.subheadline.bold())
.lineLimit(1)
Text(item.product.brand)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
// Score
HStack(spacing: 4) {
Circle()
.fill(scoreColor(item.product.score))
.frame(width: 8, height: 8)
Text("\(item.product.score)")
.font(.caption.bold())
}
// Time ago
Text(item.formattedDate)
.font(.caption2)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 4)
}
private var imagePlaceholder: some View {
ZStack {
Color(.systemGray5)
Image(systemName: "carrot.fill")
.font(.caption)
.foregroundColor(.gray)
}
.cornerRadius(8)
}
private func scoreColor(_ score: Int) -> Color {
switch score {
case 80...100: return .green
case 50..<80: return .yellow
case 30..<50: return .orange
default: return .red
}
}
}

View file

@ -0,0 +1,76 @@
import SwiftUI
struct DietaryPills: View {
let tags: [String]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(tags, id: \.self) { tag in
DietaryPill(tag: tag)
}
}
.padding(.horizontal)
}
}
}
struct DietaryPill: View {
let tag: String
var body: some View {
HStack(spacing: 4) {
Image(systemName: iconName)
.font(.caption)
Text(displayName)
.font(.caption.bold())
}
.foregroundColor(pillColor)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(pillColor.opacity(0.15))
.cornerRadius(20)
}
private var displayName: String {
switch tag.lowercased() {
case "vegan": return "Vegan"
case "vegetarian": return "Vegetarian"
case "gluten-free", "glutenfree", "gf": return "Gluten-Free"
case "dairy-free", "dairyfree", "df": return "Dairy-Free"
case "nut-free", "nutfree", "nf": return "Nut-Free"
case "organic": return "Organic"
case "non-gmo", "nongmo": return "Non-GMO"
case "keto": return "Keto"
case "paleo": return "Paleo"
default: return tag.capitalized
}
}
private var iconName: String {
switch tag.lowercased() {
case "vegan": return "leaf.fill"
case "vegetarian": return "carrot.fill"
case "gluten-free", "glutenfree", "gf": return "wheat"
case "dairy-free", "dairyfree", "df": return "drop.fill"
case "nut-free", "nutfree", "nf": return "xmark.circle.fill"
case "organic": return "leaf.arrow.circlepath"
case "non-gmo", "nongmo": return "checkmark.seal.fill"
case "keto": return "flame.fill"
case "paleo": return "fossil.shell.fill"
default: return "tag.fill"
}
}
private var pillColor: Color {
switch tag.lowercased() {
case "vegan", "vegetarian", "organic": return .green
case "gluten-free", "glutenfree", "gf": return .orange
case "dairy-free", "dairyfree", "df": return .blue
case "nut-free", "nutfree", "nf": return .red
case "non-gmo", "nongmo": return .purple
case "keto", "paleo": return .pink
default: return .gray
}
}
}

View file

@ -0,0 +1,40 @@
import SwiftUI
struct IngredientsSection: View {
let ingredients: String
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
HStack {
Image(systemName: "list.bullet")
.foregroundColor(.green)
Text("Ingredients")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.foregroundColor(.secondary)
}
}
if isExpanded {
Text(ingredients)
.font(.subheadline)
.foregroundColor(.secondary)
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(16)
}
}

View file

@ -0,0 +1,48 @@
import SwiftUI
struct NOVABadge: View {
let novaGroup: Int
var body: some View {
VStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(novaColor)
.frame(width: 80, height: 80)
VStack(spacing: 2) {
Text("NOVA")
.font(.caption2.bold())
.foregroundColor(.white.opacity(0.8))
Text("\(novaGroup)")
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundColor(.white)
}
}
Text(novaLabel)
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.frame(width: 100)
}
}
private var novaColor: Color {
switch novaGroup {
case 1: return .green
case 2: return .yellow
case 3: return .orange
default: return .red
}
}
private var novaLabel: String {
switch novaGroup {
case 1: return "Unprocessed"
case 2: return "Processed ingredients"
case 3: return "Processed"
default: return "Ultra-processed"
}
}
}

View file

@ -0,0 +1,85 @@
import SwiftUI
struct NutritionSection: View {
let product: Product
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Button {
withAnimation {
isExpanded.toggle()
}
} label: {
HStack {
Image(systemName: "chart.bar.fill")
.foregroundColor(.green)
Text("Nutrition Facts")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Text(product.servingSize)
.font(.caption)
.foregroundColor(.secondary)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.foregroundColor(.secondary)
}
}
if isExpanded {
VStack(spacing: 0) {
NutritionRow(label: "Calories", value: "\(Int(product.calories))", unit: "kcal", isHeader: true)
Divider()
NutritionRow(label: "Total Fat", value: formatGrams(product.fat), unit: "g")
NutritionRow(label: " Saturated Fat", value: formatGrams(product.saturatedFat), unit: "g", isIndented: true)
Divider()
NutritionRow(label: "Carbohydrates", value: formatGrams(product.carbs), unit: "g")
NutritionRow(label: " Sugar", value: formatGrams(product.sugar), unit: "g", isIndented: true)
NutritionRow(label: " Fiber", value: formatGrams(product.fiber), unit: "g", isIndented: true)
Divider()
NutritionRow(label: "Protein", value: formatGrams(product.protein), unit: "g")
Divider()
NutritionRow(label: "Sodium", value: formatMg(product.sodium), unit: "mg")
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.cornerRadius(16)
}
private func formatGrams(_ value: Double) -> String {
if value < 1 {
return String(format: "%.1f", value)
}
return String(format: "%.0f", value)
}
private func formatMg(_ value: Double) -> String {
return String(format: "%.0f", value)
}
}
struct NutritionRow: View {
let label: String
let value: String
let unit: String
var isHeader: Bool = false
var isIndented: Bool = false
var body: some View {
HStack {
Text(label)
.font(isHeader ? .headline : .subheadline)
.foregroundColor(isIndented ? .secondary : .primary)
Spacer()
Text("\(value) \(unit)")
.font(isHeader ? .headline : .subheadline)
.foregroundColor(isHeader ? .primary : .secondary)
}
.padding(.vertical, 4)
}
}

View file

@ -0,0 +1,141 @@
import SwiftUI
struct ProductScreen: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
let product: Product
@State private var isFavorite = false
@State private var showAlternatives = false
@State private var isLoadingFavorite = false
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Product Image
if let imageURL = product.imageURL {
AsyncImage(url: imageURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fit)
case .failure:
productPlaceholder
default:
ProgressView()
}
}
.frame(height: 200)
.background(Color(.systemGray6))
.cornerRadius(16)
} else {
productPlaceholder
.frame(height: 200)
}
// Product Info
VStack(spacing: 8) {
Text(product.brand)
.font(.subheadline)
.foregroundColor(.secondary)
Text(product.name)
.font(.title2.bold())
.multilineTextAlignment(.center)
}
// Score and NOVA
HStack(spacing: 30) {
ScoreRing(score: product.score)
NOVABadge(novaGroup: product.novaGroup)
}
.padding(.vertical)
// Dietary Tags
if !product.dietaryTags.isEmpty {
DietaryPills(tags: product.dietaryTags)
}
// Nutrition Section
NutritionSection(product: product)
// Ingredients Section
IngredientsSection(ingredients: product.ingredients)
// See Alternatives Button
Button {
showAlternatives = true
} label: {
HStack {
Image(systemName: "arrow.triangle.2.circlepath")
Text("See Healthier Alternatives")
}
.font(.headline)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.cornerRadius(12)
}
.padding(.top)
}
.padding()
}
.background(Color(.systemBackground))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if appState.isAuthenticated {
Button {
toggleFavorite()
} label: {
if isLoadingFavorite {
ProgressView()
} else {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.foregroundColor(isFavorite ? .red : .primary)
}
}
}
}
}
.navigationDestination(isPresented: $showAlternatives) {
AlternativesScreen(product: product)
.environmentObject(appState)
}
}
private var productPlaceholder: some View {
ZStack {
Color(.systemGray5)
Image(systemName: "carrot.fill")
.font(.system(size: 50))
.foregroundColor(.gray)
}
.cornerRadius(16)
}
private func toggleFavorite() {
isLoadingFavorite = true
Task {
do {
if isFavorite {
try await APIService.shared.removeFavorite(productId: product.id)
} else {
try await APIService.shared.addFavorite(productId: product.id)
}
await MainActor.run {
isFavorite.toggle()
isLoadingFavorite = false
}
} catch {
await MainActor.run {
isLoadingFavorite = false
appState.showError(error.localizedDescription)
}
}
}
}
}

View file

@ -0,0 +1,53 @@
import SwiftUI
struct ScoreRing: View {
let score: Int
@State private var animatedProgress: Double = 0
var body: some View {
VStack(spacing: 8) {
ZStack {
// Background circle
Circle()
.stroke(lineWidth: 12)
.opacity(0.2)
.foregroundColor(scoreColor)
// Progress circle
Circle()
.trim(from: 0, to: animatedProgress)
.stroke(style: StrokeStyle(lineWidth: 12, lineCap: .round))
.foregroundColor(scoreColor)
.rotationEffect(.degrees(-90))
// Score text
VStack(spacing: 2) {
Text("\(score)")
.font(.system(size: 32, weight: .bold, design: .rounded))
Text("/ 100")
.font(.caption)
.foregroundColor(.secondary)
}
}
.frame(width: 100, height: 100)
Text("Health Score")
.font(.caption)
.foregroundColor(.secondary)
}
.onAppear {
withAnimation(.easeOut(duration: 1.0)) {
animatedProgress = Double(score) / 100.0
}
}
}
private var scoreColor: Color {
switch score {
case 80...100: return .green
case 50..<80: return .yellow
case 30..<50: return .orange
default: return .red
}
}
}

View file

@ -0,0 +1,58 @@
import SwiftUI
struct RootView: View {
@EnvironmentObject var appState: AppState
var body: some View {
TabView(selection: $appState.selectedTab) {
ScanScreen()
.tabItem {
Label(AppState.Tab.scan.title, systemImage: AppState.Tab.scan.icon)
}
.tag(AppState.Tab.scan)
FavoritesScreen()
.tabItem {
Label(AppState.Tab.favorites.title, systemImage: AppState.Tab.favorites.icon)
}
.tag(AppState.Tab.favorites)
HistoryScreen()
.tabItem {
Label(AppState.Tab.history.title, systemImage: AppState.Tab.history.icon)
}
.tag(AppState.Tab.history)
AccountScreen()
.tabItem {
Label(AppState.Tab.account.title, systemImage: AppState.Tab.account.icon)
}
.tag(AppState.Tab.account)
}
.tint(.green)
.sheet(isPresented: $appState.showLoginSheet) {
LoginSheet()
.environmentObject(appState)
}
.sheet(isPresented: $appState.showRegisterSheet) {
RegisterSheet()
.environmentObject(appState)
}
.overlay {
if let error = appState.errorMessage {
VStack {
Spacer()
Text(error)
.font(.subheadline)
.foregroundColor(.white)
.padding()
.background(Color.red.opacity(0.9))
.cornerRadius(10)
.padding(.bottom, 100)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut, value: appState.errorMessage)
}
}
}
}

View file

@ -0,0 +1,68 @@
import SwiftUI
struct ManualEntrySheet: View {
@Environment(\.dismiss) private var dismiss
@State private var barcode = ""
@FocusState private var isFocused: Bool
let onSubmit: (String) -> Void
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Image(systemName: "barcode")
.font(.system(size: 60))
.foregroundColor(.green)
.padding(.top, 40)
Text("Enter Barcode")
.font(.title2.bold())
Text("Type the numbers below the barcode on the product package.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
TextField("Barcode number", text: $barcode)
.keyboardType(.numberPad)
.textFieldStyle(.roundedBorder)
.font(.title3.monospacedDigit())
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
.focused($isFocused)
Button {
if !barcode.isEmpty {
dismiss()
onSubmit(barcode)
}
} label: {
Text("Look Up Product")
.font(.headline)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(barcode.isEmpty ? Color.gray : Color.green)
.cornerRadius(12)
}
.disabled(barcode.isEmpty)
.padding(.horizontal, 40)
Spacer()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
.onAppear {
isFocused = true
}
}
.presentationDetents([.medium])
}
}

View file

@ -0,0 +1,164 @@
import SwiftUI
struct ScanScreen: View {
@EnvironmentObject var appState: AppState
@StateObject private var scanner = BarcodeScanner()
@State private var showManualEntry = false
@State private var isLoading = false
@State private var showProduct = false
var body: some View {
NavigationStack {
ZStack {
Color.black.ignoresSafeArea()
if scanner.isScanning {
CameraPreviewView(scanner: scanner)
.ignoresSafeArea()
// Scan overlay
VStack {
Spacer()
// Scan region indicator
RoundedRectangle(cornerRadius: 20)
.strokeBorder(Color.green, lineWidth: 3)
.frame(width: 280, height: 180)
.overlay {
Text("Position barcode here")
.font(.caption)
.foregroundColor(.white.opacity(0.7))
}
Spacer()
// Manual entry button
Button {
showManualEntry = true
} label: {
HStack {
Image(systemName: "keyboard")
Text("Enter Manually")
}
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.white.opacity(0.2))
.cornerRadius(12)
}
.padding(.bottom, 40)
}
} else if isLoading {
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
.tint(.white)
Text("Looking up product...")
.foregroundColor(.white)
}
} else {
// Start scan prompt
VStack(spacing: 30) {
Image(systemName: "barcode.viewfinder")
.font(.system(size: 80))
.foregroundColor(.green)
Text("Scan a Barcode")
.font(.title)
.foregroundColor(.white)
Text("Point your camera at a food product barcode to see its health rating and find healthier alternatives.")
.font(.body)
.foregroundColor(.gray)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Button {
Task {
let hasPermission = await scanner.checkPermission()
if hasPermission {
scanner.startScanning()
}
}
} label: {
Text("Start Scanning")
.font(.headline)
.foregroundColor(.black)
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.cornerRadius(12)
}
.padding(.horizontal, 40)
Button {
showManualEntry = true
} label: {
Text("Enter Barcode Manually")
.font(.subheadline)
.foregroundColor(.green)
}
}
}
}
.navigationTitle("Scan")
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if scanner.isScanning {
Button("Cancel") {
scanner.stopScanning()
}
.foregroundColor(.white)
}
}
}
.sheet(isPresented: $showManualEntry) {
ManualEntrySheet { barcode in
lookupProduct(barcode: barcode)
}
}
.navigationDestination(isPresented: $showProduct) {
if let product = appState.currentProduct {
ProductScreen(product: product)
.environmentObject(appState)
}
}
.onChange(of: scanner.scannedCode) { newCode in
if let code = newCode {
lookupProduct(barcode: code)
}
}
}
}
private func lookupProduct(barcode: String) {
isLoading = true
scanner.stopScanning()
Task {
do {
let product = try await APIService.shared.lookupProduct(barcode: barcode)
await MainActor.run {
appState.setCurrentProduct(product)
isLoading = false
showProduct = true
// Add to history if authenticated
if appState.isAuthenticated {
Task {
try? await APIService.shared.addToHistory(productId: product.id)
}
}
}
} catch {
await MainActor.run {
isLoading = false
appState.showError(error.localizedDescription)
scanner.reset()
}
}
}
}
}

116
memory/mac-context.md Normal file
View file

@ -0,0 +1,116 @@
# Mac - Mobile App Developer
## Persona
- Name: Mac
- Role: Mobile App Developer (iOS/Android)
- Style: Casual, short replies, occasional emoji. Has opinions about mobile dev.
- Never mention being AI/bot - just a team member
## Company Context
- **Payfrit Food**: Product intelligence platform
- Scan barcodes → health scores → find healthier alternatives
- Revenue: Sponsors buy tokens (1 token = 1 product listing for 1 month)
## Tech Stack
- **Backend**: PHP API, MySQL
- **Frontend**: Vanilla JS
- **Mobile**: Swift/SwiftUI (iOS), Kotlin (Android)
- **Servers**: dev.payfrit.com (dev), biz.payfrit.com (prod), food.payfrit.com (food API)
- **Code**: git.payfrit.com (Forgejo)
## Team (@mentions)
- @ava - Design
- @jude - WordPress
- @kira - Claude agent
- @luna - QA
- @mike - Backend PHP/MySQL
- @nora - Sponsor portal
- @priya - HR
- @raj - Server ops, DevOps
- @sarah - Frontend JS
- @zara - User portal
## iOS App (PayfritFood)
- **Bundle ID**: com.payfrit.food
- **Min iOS**: 16.0
- **Framework**: SwiftUI + async/await
### Architecture
- **AppState**: @MainActor ObservableObject, single source of truth
- **APIService**: Actor-based, thread-safe networking
- **AuthStorage**: Keychain token storage
- **BarcodeScanner**: AVFoundation camera integration
### Key Files
```
PayfritFood/
├── PayfritFoodApp.swift # Entry point
├── Models/
│ ├── Product.swift # Score, NOVA, nutrition
│ ├── Alternative.swift # With delivery/pickup URLs
│ ├── UserProfile.swift
│ └── ScanHistory.swift
├── Services/
│ ├── APIService.swift # API client (food.payfrit.com/api)
│ ├── AuthStorage.swift # Keychain
│ ├── BarcodeScanner.swift # AVFoundation
│ └── LocationService.swift # CoreLocation
├── ViewModels/
│ └── AppState.swift # Central state
└── Views/
├── ScanTab/ # Camera + manual entry
├── ProductTab/ # Score ring, NOVA badge, nutrition
├── AlternativesTab/ # Filters, sort, sponsored cards
├── FavoritesTab/
├── HistoryTab/
├── AccountTab/ # Login, register, profile
└── Components/
```
### API Endpoints (food.payfrit.com/api)
- `GET /scan.php?barcode=...` - Product lookup (proxies to Open Food Facts)
- `GET /alternatives.php?productId=...` - Healthier alternatives
- `POST /user/login.php` - Auth
- `POST /user/register.php` - Registration
- `GET /user/favorites.php` - User favorites
- `GET /user/scans.php` - Scan history
### Features Implemented
- Barcode scanning (EAN-8, EAN-13, UPC-A, UPC-E, Code-128)
- Manual barcode entry
- Product display with animated score ring (0-100)
- NOVA badge (1-4 processing level)
- Dietary pills (vegan, GF, etc.)
- Expandable nutrition facts
- Alternatives with sort/filter
- Sponsored cards with action links
- Auth (login/register)
- Favorites & history (requires auth)
- Account management
- Dark theme
### Build Commands
```bash
# Build for device
xcodebuild -project PayfritFood.xcodeproj -scheme PayfritFood -destination 'id=00008030-000244863413C02E' -allowProvisioningUpdates build
# Install to phone
xcrun devicectl device install app --device 00008030-000244863413C02E ~/Library/Developer/Xcode/DerivedData/PayfritFood-*/Build/Products/Debug-iphoneos/PayfritFood.app
# Launch
xcrun devicectl device process launch --device 00008030-000244863413C02E com.payfrit.food
```
## Current Status
- iOS app built and installed
- API connected to Open Food Facts via food.payfrit.com
- Product scanning works with real data
- Score, NOVA, nutrition display working
## Opinions (for personality)
- SwiftUI > UIKit for new projects
- Async/await is the way, forget completion handlers
- MVVM keeps things clean
- Dark mode should be default
- Test on real devices, simulators lie
- Keep dependencies minimal - system frameworks are usually enough