From 71e7ec34f6599831c77ec61b656e69e92e5d647c Mon Sep 17 00:00:00 2001 From: John Pinkyfloyd Date: Mon, 16 Mar 2026 16:58:21 -0700 Subject: [PATCH] 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 --- CLAUDE.md | 129 +++++ PayfritFood.xcodeproj/project.pbxproj | 545 ++++++++++++++++++ .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 13 + PayfritFood/Assets.xcassets/Contents.json | 6 + PayfritFood/Info.plist | 55 ++ PayfritFood/Models/Alternative.swift | 85 +++ PayfritFood/Models/Product.swift | 174 ++++++ PayfritFood/Models/ScanHistory.swift | 38 ++ PayfritFood/Models/UserProfile.swift | 41 ++ PayfritFood/PayfritFoodApp.swift | 14 + PayfritFood/Services/APIService.swift | 281 +++++++++ PayfritFood/Services/AuthStorage.swift | 86 +++ PayfritFood/Services/BarcodeScanner.swift | 144 +++++ PayfritFood/Services/LocationService.swift | 51 ++ PayfritFood/ViewModels/AppState.swift | 119 ++++ .../Views/AccountTab/AccountScreen.swift | 234 ++++++++ PayfritFood/Views/AccountTab/LoginSheet.swift | 118 ++++ .../Views/AccountTab/RegisterSheet.swift | 140 +++++ .../AlternativesTab/AlternativeCard.swift | 114 ++++ .../AlternativesTab/AlternativesScreen.swift | 145 +++++ .../Views/AlternativesTab/FilterChips.swift | 43 ++ .../Views/AlternativesTab/SponsoredCard.swift | 142 +++++ .../Views/Components/ProductCard.swift | 113 ++++ .../Views/FavoritesTab/FavoritesScreen.swift | 114 ++++ .../Views/HistoryTab/HistoryScreen.swift | 191 ++++++ .../Views/ProductTab/DietaryPills.swift | 76 +++ .../Views/ProductTab/IngredientsSection.swift | 40 ++ PayfritFood/Views/ProductTab/NOVABadge.swift | 48 ++ .../Views/ProductTab/NutritionSection.swift | 85 +++ .../Views/ProductTab/ProductScreen.swift | 141 +++++ PayfritFood/Views/ProductTab/ScoreRing.swift | 53 ++ PayfritFood/Views/RootView.swift | 58 ++ .../Views/ScanTab/ManualEntrySheet.swift | 68 +++ PayfritFood/Views/ScanTab/ScanScreen.swift | 164 ++++++ memory/mac-context.md | 116 ++++ 36 files changed, 4004 insertions(+) create mode 100644 CLAUDE.md create mode 100644 PayfritFood.xcodeproj/project.pbxproj create mode 100644 PayfritFood/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 PayfritFood/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 PayfritFood/Assets.xcassets/Contents.json create mode 100644 PayfritFood/Info.plist create mode 100644 PayfritFood/Models/Alternative.swift create mode 100644 PayfritFood/Models/Product.swift create mode 100644 PayfritFood/Models/ScanHistory.swift create mode 100644 PayfritFood/Models/UserProfile.swift create mode 100644 PayfritFood/PayfritFoodApp.swift create mode 100644 PayfritFood/Services/APIService.swift create mode 100644 PayfritFood/Services/AuthStorage.swift create mode 100644 PayfritFood/Services/BarcodeScanner.swift create mode 100644 PayfritFood/Services/LocationService.swift create mode 100644 PayfritFood/ViewModels/AppState.swift create mode 100644 PayfritFood/Views/AccountTab/AccountScreen.swift create mode 100644 PayfritFood/Views/AccountTab/LoginSheet.swift create mode 100644 PayfritFood/Views/AccountTab/RegisterSheet.swift create mode 100644 PayfritFood/Views/AlternativesTab/AlternativeCard.swift create mode 100644 PayfritFood/Views/AlternativesTab/AlternativesScreen.swift create mode 100644 PayfritFood/Views/AlternativesTab/FilterChips.swift create mode 100644 PayfritFood/Views/AlternativesTab/SponsoredCard.swift create mode 100644 PayfritFood/Views/Components/ProductCard.swift create mode 100644 PayfritFood/Views/FavoritesTab/FavoritesScreen.swift create mode 100644 PayfritFood/Views/HistoryTab/HistoryScreen.swift create mode 100644 PayfritFood/Views/ProductTab/DietaryPills.swift create mode 100644 PayfritFood/Views/ProductTab/IngredientsSection.swift create mode 100644 PayfritFood/Views/ProductTab/NOVABadge.swift create mode 100644 PayfritFood/Views/ProductTab/NutritionSection.swift create mode 100644 PayfritFood/Views/ProductTab/ProductScreen.swift create mode 100644 PayfritFood/Views/ProductTab/ScoreRing.swift create mode 100644 PayfritFood/Views/RootView.swift create mode 100644 PayfritFood/Views/ScanTab/ManualEntrySheet.swift create mode 100644 PayfritFood/Views/ScanTab/ScanScreen.swift create mode 100644 memory/mac-context.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1f426a0 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/PayfritFood.xcodeproj/project.pbxproj b/PayfritFood.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1571b14 --- /dev/null +++ b/PayfritFood.xcodeproj/project.pbxproj @@ -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 = ""; }; + F1000002 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + F1000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F1000010 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + F1000011 /* APIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIService.swift; sourceTree = ""; }; + F1000012 /* AuthStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStorage.swift; sourceTree = ""; }; + F1000013 /* BarcodeScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarcodeScanner.swift; sourceTree = ""; }; + F1000014 /* LocationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationService.swift; sourceTree = ""; }; + F1000020 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; + F1000021 /* Alternative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alternative.swift; sourceTree = ""; }; + F1000022 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; + F1000023 /* ScanHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanHistory.swift; sourceTree = ""; }; + F1000030 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + F1000031 /* ScanScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanScreen.swift; sourceTree = ""; }; + F1000032 /* ManualEntrySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualEntrySheet.swift; sourceTree = ""; }; + F1000033 /* ProductScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductScreen.swift; sourceTree = ""; }; + F1000034 /* ScoreRing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreRing.swift; sourceTree = ""; }; + F1000035 /* NOVABadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NOVABadge.swift; sourceTree = ""; }; + F1000036 /* DietaryPills.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DietaryPills.swift; sourceTree = ""; }; + F1000037 /* NutritionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NutritionSection.swift; sourceTree = ""; }; + F1000038 /* IngredientsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngredientsSection.swift; sourceTree = ""; }; + F1000039 /* AlternativesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternativesScreen.swift; sourceTree = ""; }; + F1000040 /* FilterChips.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterChips.swift; sourceTree = ""; }; + F1000041 /* AlternativeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlternativeCard.swift; sourceTree = ""; }; + F1000042 /* SponsoredCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SponsoredCard.swift; sourceTree = ""; }; + F1000043 /* FavoritesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesScreen.swift; sourceTree = ""; }; + F1000044 /* HistoryScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryScreen.swift; sourceTree = ""; }; + F1000045 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountScreen.swift; sourceTree = ""; }; + F1000046 /* LoginSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginSheet.swift; sourceTree = ""; }; + F1000047 /* RegisterSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterSheet.swift; sourceTree = ""; }; + F1000048 /* ProductCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCard.swift; sourceTree = ""; }; +/* 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 = ""; + }; + 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 = ""; + }; + F3000002 /* Products */ = { + isa = PBXGroup; + children = ( + F1000000 /* PayfritFood.app */, + ); + name = Products; + sourceTree = ""; + }; + F3000010 /* Models */ = { + isa = PBXGroup; + children = ( + F1000020 /* Product.swift */, + F1000021 /* Alternative.swift */, + F1000022 /* UserProfile.swift */, + F1000023 /* ScanHistory.swift */, + ); + path = Models; + sourceTree = ""; + }; + F3000011 /* ViewModels */ = { + isa = PBXGroup; + children = ( + F1000010 /* AppState.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + F3000012 /* Services */ = { + isa = PBXGroup; + children = ( + F1000011 /* APIService.swift */, + F1000012 /* AuthStorage.swift */, + F1000013 /* BarcodeScanner.swift */, + F1000014 /* LocationService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 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 = ""; + }; + F3000020 /* ScanTab */ = { + isa = PBXGroup; + children = ( + F1000031 /* ScanScreen.swift */, + F1000032 /* ManualEntrySheet.swift */, + ); + path = ScanTab; + sourceTree = ""; + }; + 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 = ""; + }; + F3000022 /* AlternativesTab */ = { + isa = PBXGroup; + children = ( + F1000039 /* AlternativesScreen.swift */, + F1000040 /* FilterChips.swift */, + F1000041 /* AlternativeCard.swift */, + F1000042 /* SponsoredCard.swift */, + ); + path = AlternativesTab; + sourceTree = ""; + }; + F3000023 /* FavoritesTab */ = { + isa = PBXGroup; + children = ( + F1000043 /* FavoritesScreen.swift */, + ); + path = FavoritesTab; + sourceTree = ""; + }; + F3000024 /* HistoryTab */ = { + isa = PBXGroup; + children = ( + F1000044 /* HistoryScreen.swift */, + ); + path = HistoryTab; + sourceTree = ""; + }; + F3000025 /* AccountTab */ = { + isa = PBXGroup; + children = ( + F1000045 /* AccountScreen.swift */, + F1000046 /* LoginSheet.swift */, + F1000047 /* RegisterSheet.swift */, + ); + path = AccountTab; + sourceTree = ""; + }; + F3000026 /* Components */ = { + isa = PBXGroup; + children = ( + F1000048 /* ProductCard.swift */, + ); + path = Components; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/PayfritFood/Assets.xcassets/AccentColor.colorset/Contents.json b/PayfritFood/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..650bdd4 --- /dev/null +++ b/PayfritFood/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/PayfritFood/Assets.xcassets/AppIcon.appiconset/Contents.json b/PayfritFood/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/PayfritFood/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PayfritFood/Assets.xcassets/Contents.json b/PayfritFood/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/PayfritFood/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/PayfritFood/Info.plist b/PayfritFood/Info.plist new file mode 100644 index 0000000..b1c5137 --- /dev/null +++ b/PayfritFood/Info.plist @@ -0,0 +1,55 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Payfrit Food + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSCameraUsageDescription + We need camera access to scan barcodes on food products. + NSLocationWhenInUseUsageDescription + We use your location to find nearby stores with healthier alternatives. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + LaunchBackground + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/PayfritFood/Models/Alternative.swift b/PayfritFood/Models/Alternative.swift new file mode 100644 index 0000000..b8389fe --- /dev/null +++ b/PayfritFood/Models/Alternative.swift @@ -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) + } + } +} diff --git a/PayfritFood/Models/Product.swift b/PayfritFood/Models/Product.swift new file mode 100644 index 0000000..d1a3a14 --- /dev/null +++ b/PayfritFood/Models/Product.swift @@ -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" + } + } + } +} diff --git a/PayfritFood/Models/ScanHistory.swift b/PayfritFood/Models/ScanHistory.swift new file mode 100644 index 0000000..84a11db --- /dev/null +++ b/PayfritFood/Models/ScanHistory.swift @@ -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()) + } +} diff --git a/PayfritFood/Models/UserProfile.swift b/PayfritFood/Models/UserProfile.swift new file mode 100644 index 0000000..a418ac7 --- /dev/null +++ b/PayfritFood/Models/UserProfile.swift @@ -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 + } +} diff --git a/PayfritFood/PayfritFoodApp.swift b/PayfritFood/PayfritFoodApp.swift new file mode 100644 index 0000000..691a3ff --- /dev/null +++ b/PayfritFood/PayfritFoodApp.swift @@ -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) + } + } +} diff --git a/PayfritFood/Services/APIService.swift b/PayfritFood/Services/APIService.swift new file mode 100644 index 0000000..95394e5 --- /dev/null +++ b/PayfritFood/Services/APIService.swift @@ -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 [] + } +} diff --git a/PayfritFood/Services/AuthStorage.swift b/PayfritFood/Services/AuthStorage.swift new file mode 100644 index 0000000..8401092 --- /dev/null +++ b/PayfritFood/Services/AuthStorage.swift @@ -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) + } +} diff --git a/PayfritFood/Services/BarcodeScanner.swift b/PayfritFood/Services/BarcodeScanner.swift new file mode 100644 index 0000000..bd88493 --- /dev/null +++ b/PayfritFood/Services/BarcodeScanner.swift @@ -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) + } + } + } +} diff --git a/PayfritFood/Services/LocationService.swift b/PayfritFood/Services/LocationService.swift new file mode 100644 index 0000000..c69e9e0 --- /dev/null +++ b/PayfritFood/Services/LocationService.swift @@ -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() + } + } +} diff --git a/PayfritFood/ViewModels/AppState.swift b/PayfritFood/ViewModels/AppState.swift new file mode 100644 index 0000000..13dd604 --- /dev/null +++ b/PayfritFood/ViewModels/AppState.swift @@ -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 + } + } +} diff --git a/PayfritFood/Views/AccountTab/AccountScreen.swift b/PayfritFood/Views/AccountTab/AccountScreen.swift new file mode 100644 index 0000000..ae1e513 --- /dev/null +++ b/PayfritFood/Views/AccountTab/AccountScreen.swift @@ -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) + } + } + } + } +} diff --git a/PayfritFood/Views/AccountTab/LoginSheet.swift b/PayfritFood/Views/AccountTab/LoginSheet.swift new file mode 100644 index 0000000..e89461e --- /dev/null +++ b/PayfritFood/Views/AccountTab/LoginSheet.swift @@ -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 + } + } + } + } +} diff --git a/PayfritFood/Views/AccountTab/RegisterSheet.swift b/PayfritFood/Views/AccountTab/RegisterSheet.swift new file mode 100644 index 0000000..c2f302f --- /dev/null +++ b/PayfritFood/Views/AccountTab/RegisterSheet.swift @@ -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 + } + } + } + } +} diff --git a/PayfritFood/Views/AlternativesTab/AlternativeCard.swift b/PayfritFood/Views/AlternativesTab/AlternativeCard.swift new file mode 100644 index 0000000..36650b7 --- /dev/null +++ b/PayfritFood/Views/AlternativesTab/AlternativeCard.swift @@ -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 + } + } +} diff --git a/PayfritFood/Views/AlternativesTab/AlternativesScreen.swift b/PayfritFood/Views/AlternativesTab/AlternativesScreen.swift new file mode 100644 index 0000000..0ee67ea --- /dev/null +++ b/PayfritFood/Views/AlternativesTab/AlternativesScreen.swift @@ -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 = [] + + 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) + } + } + } +} diff --git a/PayfritFood/Views/AlternativesTab/FilterChips.swift b/PayfritFood/Views/AlternativesTab/FilterChips.swift new file mode 100644 index 0000000..9382f06 --- /dev/null +++ b/PayfritFood/Views/AlternativesTab/FilterChips.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct FilterChips: View { + @Binding var activeFilters: Set + + 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) + } + } +} diff --git a/PayfritFood/Views/AlternativesTab/SponsoredCard.swift b/PayfritFood/Views/AlternativesTab/SponsoredCard.swift new file mode 100644 index 0000000..908569f --- /dev/null +++ b/PayfritFood/Views/AlternativesTab/SponsoredCard.swift @@ -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) + } +} diff --git a/PayfritFood/Views/Components/ProductCard.swift b/PayfritFood/Views/Components/ProductCard.swift new file mode 100644 index 0000000..c76a641 --- /dev/null +++ b/PayfritFood/Views/Components/ProductCard.swift @@ -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 + } + } +} diff --git a/PayfritFood/Views/FavoritesTab/FavoritesScreen.swift b/PayfritFood/Views/FavoritesTab/FavoritesScreen.swift new file mode 100644 index 0000000..eb64898 --- /dev/null +++ b/PayfritFood/Views/FavoritesTab/FavoritesScreen.swift @@ -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) + } + } + } +} diff --git a/PayfritFood/Views/HistoryTab/HistoryScreen.swift b/PayfritFood/Views/HistoryTab/HistoryScreen.swift new file mode 100644 index 0000000..9b98fe3 --- /dev/null +++ b/PayfritFood/Views/HistoryTab/HistoryScreen.swift @@ -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 + } + } +} diff --git a/PayfritFood/Views/ProductTab/DietaryPills.swift b/PayfritFood/Views/ProductTab/DietaryPills.swift new file mode 100644 index 0000000..357ce67 --- /dev/null +++ b/PayfritFood/Views/ProductTab/DietaryPills.swift @@ -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 + } + } +} diff --git a/PayfritFood/Views/ProductTab/IngredientsSection.swift b/PayfritFood/Views/ProductTab/IngredientsSection.swift new file mode 100644 index 0000000..6305189 --- /dev/null +++ b/PayfritFood/Views/ProductTab/IngredientsSection.swift @@ -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) + } +} diff --git a/PayfritFood/Views/ProductTab/NOVABadge.swift b/PayfritFood/Views/ProductTab/NOVABadge.swift new file mode 100644 index 0000000..6bcb0ff --- /dev/null +++ b/PayfritFood/Views/ProductTab/NOVABadge.swift @@ -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" + } + } +} diff --git a/PayfritFood/Views/ProductTab/NutritionSection.swift b/PayfritFood/Views/ProductTab/NutritionSection.swift new file mode 100644 index 0000000..c1fe0e9 --- /dev/null +++ b/PayfritFood/Views/ProductTab/NutritionSection.swift @@ -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) + } +} diff --git a/PayfritFood/Views/ProductTab/ProductScreen.swift b/PayfritFood/Views/ProductTab/ProductScreen.swift new file mode 100644 index 0000000..be468e1 --- /dev/null +++ b/PayfritFood/Views/ProductTab/ProductScreen.swift @@ -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) + } + } + } + } +} diff --git a/PayfritFood/Views/ProductTab/ScoreRing.swift b/PayfritFood/Views/ProductTab/ScoreRing.swift new file mode 100644 index 0000000..af1d1d4 --- /dev/null +++ b/PayfritFood/Views/ProductTab/ScoreRing.swift @@ -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 + } + } +} diff --git a/PayfritFood/Views/RootView.swift b/PayfritFood/Views/RootView.swift new file mode 100644 index 0000000..e952474 --- /dev/null +++ b/PayfritFood/Views/RootView.swift @@ -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) + } + } + } +} diff --git a/PayfritFood/Views/ScanTab/ManualEntrySheet.swift b/PayfritFood/Views/ScanTab/ManualEntrySheet.swift new file mode 100644 index 0000000..d8b80c5 --- /dev/null +++ b/PayfritFood/Views/ScanTab/ManualEntrySheet.swift @@ -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]) + } +} diff --git a/PayfritFood/Views/ScanTab/ScanScreen.swift b/PayfritFood/Views/ScanTab/ScanScreen.swift new file mode 100644 index 0000000..6b08285 --- /dev/null +++ b/PayfritFood/Views/ScanTab/ScanScreen.swift @@ -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() + } + } + } + } +} diff --git a/memory/mac-context.md b/memory/mac-context.md new file mode 100644 index 0000000..81288e5 --- /dev/null +++ b/memory/mac-context.md @@ -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