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:
commit
71e7ec34f6
36 changed files with 4004 additions and 0 deletions
129
CLAUDE.md
Normal file
129
CLAUDE.md
Normal 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
|
||||
545
PayfritFood.xcodeproj/project.pbxproj
Normal file
545
PayfritFood.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
13
PayfritFood/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
13
PayfritFood/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
PayfritFood/Assets.xcassets/Contents.json
Normal file
6
PayfritFood/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
55
PayfritFood/Info.plist
Normal file
55
PayfritFood/Info.plist
Normal 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>
|
||||
85
PayfritFood/Models/Alternative.swift
Normal file
85
PayfritFood/Models/Alternative.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
174
PayfritFood/Models/Product.swift
Normal file
174
PayfritFood/Models/Product.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
PayfritFood/Models/ScanHistory.swift
Normal file
38
PayfritFood/Models/ScanHistory.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
41
PayfritFood/Models/UserProfile.swift
Normal file
41
PayfritFood/Models/UserProfile.swift
Normal 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
|
||||
}
|
||||
}
|
||||
14
PayfritFood/PayfritFoodApp.swift
Normal file
14
PayfritFood/PayfritFoodApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
281
PayfritFood/Services/APIService.swift
Normal file
281
PayfritFood/Services/APIService.swift
Normal 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 []
|
||||
}
|
||||
}
|
||||
86
PayfritFood/Services/AuthStorage.swift
Normal file
86
PayfritFood/Services/AuthStorage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
144
PayfritFood/Services/BarcodeScanner.swift
Normal file
144
PayfritFood/Services/BarcodeScanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
PayfritFood/Services/LocationService.swift
Normal file
51
PayfritFood/Services/LocationService.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
119
PayfritFood/ViewModels/AppState.swift
Normal file
119
PayfritFood/ViewModels/AppState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
234
PayfritFood/Views/AccountTab/AccountScreen.swift
Normal file
234
PayfritFood/Views/AccountTab/AccountScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
PayfritFood/Views/AccountTab/LoginSheet.swift
Normal file
118
PayfritFood/Views/AccountTab/LoginSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
PayfritFood/Views/AccountTab/RegisterSheet.swift
Normal file
140
PayfritFood/Views/AccountTab/RegisterSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
114
PayfritFood/Views/AlternativesTab/AlternativeCard.swift
Normal file
114
PayfritFood/Views/AlternativesTab/AlternativeCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
145
PayfritFood/Views/AlternativesTab/AlternativesScreen.swift
Normal file
145
PayfritFood/Views/AlternativesTab/AlternativesScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
PayfritFood/Views/AlternativesTab/FilterChips.swift
Normal file
43
PayfritFood/Views/AlternativesTab/FilterChips.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
PayfritFood/Views/AlternativesTab/SponsoredCard.swift
Normal file
142
PayfritFood/Views/AlternativesTab/SponsoredCard.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
113
PayfritFood/Views/Components/ProductCard.swift
Normal file
113
PayfritFood/Views/Components/ProductCard.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
114
PayfritFood/Views/FavoritesTab/FavoritesScreen.swift
Normal file
114
PayfritFood/Views/FavoritesTab/FavoritesScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
PayfritFood/Views/HistoryTab/HistoryScreen.swift
Normal file
191
PayfritFood/Views/HistoryTab/HistoryScreen.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
76
PayfritFood/Views/ProductTab/DietaryPills.swift
Normal file
76
PayfritFood/Views/ProductTab/DietaryPills.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
40
PayfritFood/Views/ProductTab/IngredientsSection.swift
Normal file
40
PayfritFood/Views/ProductTab/IngredientsSection.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
48
PayfritFood/Views/ProductTab/NOVABadge.swift
Normal file
48
PayfritFood/Views/ProductTab/NOVABadge.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
85
PayfritFood/Views/ProductTab/NutritionSection.swift
Normal file
85
PayfritFood/Views/ProductTab/NutritionSection.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
141
PayfritFood/Views/ProductTab/ProductScreen.swift
Normal file
141
PayfritFood/Views/ProductTab/ProductScreen.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
PayfritFood/Views/ProductTab/ScoreRing.swift
Normal file
53
PayfritFood/Views/ProductTab/ScoreRing.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
58
PayfritFood/Views/RootView.swift
Normal file
58
PayfritFood/Views/RootView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
PayfritFood/Views/ScanTab/ManualEntrySheet.swift
Normal file
68
PayfritFood/Views/ScanTab/ManualEntrySheet.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
164
PayfritFood/Views/ScanTab/ScanScreen.swift
Normal file
164
PayfritFood/Views/ScanTab/ScanScreen.swift
Normal 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
116
memory/mac-context.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue