3 Commits

Author SHA1 Message Date
VincentMeilinger
48b31a7997 Nextcloud Login refactoring 2025-05-31 11:12:14 +02:00
VincentMeilinger
5acf3b9c4f WIP - Complete App refactoring 2025-05-26 15:52:24 +02:00
VincentMeilinger
29fd3c668b WIP - Complete App refactoring 2025-05-26 15:52:12 +02:00
66 changed files with 4509 additions and 1401 deletions

114
CLAUDE.md
View File

@@ -1,114 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Nextcloud Cookbook iOS Client — a native iOS/iPadOS/macOS (Mac Catalyst) client for the [Nextcloud Cookbook](https://github.com/nextcloud/cookbook) server application. Built entirely in Swift and SwiftUI. Licensed under GPLv3.
**This is not a standalone app.** It requires a Nextcloud server with the Cookbook app installed.
## Build & Run
This is an Xcode project (no workspace, no CocoaPods). Open `Nextcloud Cookbook iOS Client.xcodeproj` in Xcode.
```bash
# Build from command line
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
-scheme "Nextcloud Cookbook iOS Client" \
-destination 'platform=iOS Simulator,name=iPhone 16' \
build
# Run tests (note: tests are currently boilerplate stubs with no real coverage)
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
-scheme "Nextcloud Cookbook iOS Client" \
-destination 'platform=iOS Simulator,name=iPhone 16' \
test
```
- **Deployment target**: iOS 16.4
- **Swift version**: 5.0
- **Targets**: iPhone and iPad, Mac Catalyst enabled
## Dependencies (SPM)
Only two third-party packages, managed via Swift Package Manager integrated in the Xcode project:
| Package | Purpose |
|---------|---------|
| [SwiftSoup](https://github.com/scinfu/SwiftSoup.git) (2.6.1) | HTML parsing for client-side recipe scraping |
| [TPPDF](https://github.com/techprimate/TPPDF.git) (2.4.1) | PDF generation for recipe export |
## Architecture
### Central State Pattern
The app uses a **centralized `AppState` ObservableObject** (`AppState.swift`) as the primary ViewModel, injected via `.environmentObject()` into the SwiftUI view hierarchy. `AppState` owns:
- All data (categories, recipes, recipe details, images, timers)
- All CRUD operations against the Cookbook API
- A `FetchMode` enum (`preferLocal`, `preferServer`, `onlyLocal`, `onlyServer`) that governs the data-fetching strategy (server-first vs local-first)
- Local persistence via `DataStore`
Additional ViewModels exist as nested classes within their views (`RecipeTabView.ViewModel`, `SearchTabView.ViewModel`) or as standalone classes (`RecipeEditViewModel`, `GroceryList`).
### Data Flow
```
SwiftUI Views
├── @EnvironmentObject appState: AppState
├── @EnvironmentObject groceryList: GroceryList
└── Per-view @StateObject ViewModels
AppState
├── cookbookApi (CookbookApiV1 — static methods) → ApiRequest → URLSession
├── DataStore (file-based JSON persistence in Documents directory)
└── UserSettings.shared (UserDefaults singleton)
```
### Network Layer
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
- The global `cookbookApi` constant (`CookbookApi.swift:14`) resolves the API version at launch.
- `ApiRequest` is a generic HTTP request builder using `URLSession.shared.data(for:)` with HTTP Basic Auth.
- `NextcloudApi` handles Nextcloud-specific auth (Login Flow v2 and token-based login).
### Persistence
- **`DataStore`**: File-based persistence using `JSONEncoder`/`JSONDecoder` writing to the app's Documents directory. No Core Data or SQLite.
- **`UserSettings`**: Singleton wrapping `UserDefaults` for all user preferences and credentials.
- **Image caching**: Two-tier — in-memory dictionary in `AppState` + on-disk base64-encoded PNG files via `DataStore`.
### Key Source Directories
```
Nextcloud Cookbook iOS Client/
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings
├── Models/ # RecipeEditViewModel
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
├── Views/
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab)
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
│ └── ReusableViews/
├── Extensions/ # Color, Date, JSONCoder, Logger extensions
├── Util/ # Alerts, DurationComponents (ISO 8601 PT parser), JsonAny, NumberFormatter
├── RecipeExport/ # PDF, text, JSON export via RecipeExporter
└── RecipeImport/ # HTML scraping via SwiftSoup (schema.org ld+json Recipe data)
```
## Localization
Four languages supported via `Localizable.xcstrings`: English, German, Spanish, French. Spanish and French are mostly machine-translated.
## Notable Design Decisions
- The `cookbookApi` global is resolved once at launch based on `UserSettings.shared.cookbookApiVersion` and uses static protocol methods, which makes dependency injection and unit testing difficult.
- Server credentials (username, token, authString) are stored in `UserDefaults` via `UserSettings`, not in Keychain.
- No `.gitignore` file exists in the repository.
- No CI/CD, no linting tools, and no meaningful test coverage.
## Workflow
- Do not run `xcodebuild` directly. Ask the user to build manually in Xcode and report the results back.

View File

@@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 56; objectVersion = 70;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@@ -21,15 +21,17 @@
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; }; A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; }; A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; }; A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; }; A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; }; A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; }; A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; }; A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; }; A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; }; A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; }; A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; }; A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
@@ -37,7 +39,6 @@
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; }; A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; }; A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; };
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; }; A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
@@ -52,17 +53,27 @@
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; }; A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; }; A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; }; A97B4D322B80B3E900EC1A88 /* CookbookModelsV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */; };
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; }; A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */; }; A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */; };
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; };
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; };
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; };
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */; };
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */; };
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB0512DE911C300A4C74B /* AuthManager.swift */; };
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; }; A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; }; A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */; }; A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; };
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; }; A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; }; A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; }; A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; };
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; }; A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */; };
A9E78A322BEA770600206866 /* NextcloudDataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */; };
A9E78A342BEA773900206866 /* LocalDataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A332BEA773900206866 /* LocalDataInterface.swift */; };
A9E78A372BEA839100206866 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A9E78A362BEA839100206866 /* KeychainSwift */; };
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; }; A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -102,15 +113,16 @@
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; }; A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; }; A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; }; A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; }; A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; }; A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; }; A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; };
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; }; A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; }; A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; }; A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; }; A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; }; A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; }; A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
@@ -118,7 +130,6 @@
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; }; A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; }; A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = "<group>"; };
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; }; A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
@@ -133,25 +144,40 @@
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; }; A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; }; A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; }; A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; }; A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookModelsV1.swift; sourceTree = "<group>"; };
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; }; A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = "<group>"; }; A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = "<group>"; };
A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; };
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; };
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; };
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVStack.swift; sourceTree = "<group>"; };
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = "<group>"; };
A9AAB0512DE911C300A4C74B /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; }; A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; }; A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = "<group>"; }; A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; };
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; }; A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; }; A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; }; A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; };
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; }; A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; }; A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataInterface.swift; sourceTree = "<group>"; };
A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudDataInterface.swift; sourceTree = "<group>"; };
A9E78A332BEA773900206866 /* LocalDataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalDataInterface.swift; sourceTree = "<group>"; };
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A9C34A722D390E69006EEB66 /* Account */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Account; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
A701717B2AA8E71900064C43 /* Frameworks */ = { A701717B2AA8E71900064C43 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A9E78A372BEA839100206866 /* KeychainSwift in Frameworks */,
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -198,6 +224,8 @@
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = { A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9C34A722D390E69006EEB66 /* Account */,
A9E78A2E2BEA726A00206866 /* Persistence */,
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */, A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */,
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */, A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
A70171AC2AA8EF4700064C43 /* AppState.swift */, A70171AC2AA8EF4700064C43 /* AppState.swift */,
@@ -205,7 +233,7 @@
A70171BA2AB4980100064C43 /* Views */, A70171BA2AB4980100064C43 /* Views */,
A70171B72AB2445700064C43 /* Models */, A70171B72AB2445700064C43 /* Models */,
A97B4D332B80B51700EC1A88 /* Util */, A97B4D332B80B51700EC1A88 /* Util */,
A70171B22AB211F000064C43 /* Network */, A70171B22AB211F000064C43 /* Networking */,
A781E75F2AF8228100452F6F /* RecipeImport */, A781E75F2AF8228100452F6F /* RecipeImport */,
A9CA6CED2B4C084100F78AB5 /* RecipeExport */, A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
A703226B2ABAF60D00D7C4ED /* Extensions */, A703226B2ABAF60D00D7C4ED /* Extensions */,
@@ -242,21 +270,22 @@
path = "Nextcloud Cookbook iOS ClientUITests"; path = "Nextcloud Cookbook iOS ClientUITests";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A70171B22AB211F000064C43 /* Network */ = { A70171B22AB211F000064C43 /* Networking */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
A70171B32AB2122900064C43 /* NetworkUtils.swift */, A70171B32AB2122900064C43 /* NetworkUtils.swift */,
A70171B02AB211DF00064C43 /* NetworkError.swift */, A70171B02AB211DF00064C43 /* NetworkError.swift */,
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
); );
path = Network; path = Networking;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A70171B72AB2445700064C43 /* Models */ = { A70171B72AB2445700064C43 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -277,11 +306,10 @@
A70171C72AB4C4A100064C43 /* Data */ = { A70171C72AB4C4A100064C43 /* Data */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9AAB0512DE911C300A4C74B /* AuthManager.swift */,
A70171C32AB4A31200064C43 /* DataStore.swift */, A70171C32AB4A31200064C43 /* DataStore.swift */,
A70171CA2AB4CD1700064C43 /* UserSettings.swift */, A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
A70171C52AB4C43A00064C43 /* DataModels.swift */, A9BBB38F2B91BE31002DA7FF /* Recipe.swift */,
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -307,6 +335,7 @@
A781E75F2AF8228100452F6F /* RecipeImport */ = { A781E75F2AF8228100452F6F /* RecipeImport */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
); );
path = RecipeImport; path = RecipeImport;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -315,7 +344,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */, A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */,
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */, A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */,
A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */,
); );
path = CookbookApi; path = CookbookApi;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -357,6 +389,7 @@
A977D0DC2B6002DA009783A9 /* Tabs */ = { A977D0DC2B6002DA009783A9 /* Tabs */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */,
A977D0DD2B600300009783A9 /* SearchTabView.swift */, A977D0DD2B600300009783A9 /* SearchTabView.swift */,
A977D0DF2B600318009783A9 /* RecipeTabView.swift */, A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
@@ -376,6 +409,16 @@
path = Util; path = Util;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A98F931F2C07BA4F00E34359 /* Interfaces */ = {
isa = PBXGroup;
children = (
A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */,
A9E78A332BEA773900206866 /* LocalDataInterface.swift */,
A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */,
);
path = Interfaces;
sourceTree = "<group>";
};
A9C3BE502B630E3900562C79 /* Recipes */ = { A9C3BE502B630E3900562C79 /* Recipes */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -392,10 +435,10 @@
A9C3BE522B630F1300562C79 /* ReusableViews */ = { A9C3BE522B630F1300562C79 /* ReusableViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */,
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */, A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */, A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */,
); );
path = ReusableViews; path = ReusableViews;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -408,6 +451,15 @@
path = RecipeExport; path = RecipeExport;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A9E78A2E2BEA726A00206866 /* Persistence */ = {
isa = PBXGroup;
children = (
A98F931D2C07B07400E34359 /* CookbookState.swift */,
A98F931F2C07BA4F00E34359 /* Interfaces */,
);
path = Persistence;
sourceTree = "<group>";
};
A9FA2AB42B50798800A43702 /* Resources */ = { A9FA2AB42B50798800A43702 /* Resources */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -431,9 +483,14 @@
); );
dependencies = ( dependencies = (
); );
fileSystemSynchronizedGroups = (
A9C34A722D390E69006EEB66 /* Account */,
);
name = "Nextcloud Cookbook iOS Client"; name = "Nextcloud Cookbook iOS Client";
packageProductDependencies = ( packageProductDependencies = (
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
A9CA6CF52B4C63F200F78AB5 /* TPPDF */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
A9E78A362BEA839100206866 /* KeychainSwift */,
); );
productName = "Nextcloud Cookbook iOS Client"; productName = "Nextcloud Cookbook iOS Client";
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */; productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
@@ -511,7 +568,9 @@
); );
mainGroup = A70171752AA8E71900064C43; mainGroup = A70171752AA8E71900064C43;
packageReferences = ( packageReferences = (
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */,
); );
productRefGroup = A701717F2AA8E71900064C43 /* Products */; productRefGroup = A701717F2AA8E71900064C43 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -562,11 +621,13 @@
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */, A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */, A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */,
A9E78A322BEA770600206866 /* NextcloudDataInterface.swift in Sources */,
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */, A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */,
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */, A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
@@ -575,21 +636,25 @@
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */,
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */, A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */,
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */, A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */, A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */,
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */,
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */,
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */, A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */, A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */, A97B4D322B80B3E900EC1A88 /* CookbookModelsV1.swift in Sources */,
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */, A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */, A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */, A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */,
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
A9E78A342BEA773900206866 /* LocalDataInterface.swift in Sources */,
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */, A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */, A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */,
@@ -599,10 +664,12 @@
A70171842AA8E71900064C43 /* MainView.swift in Sources */, A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */, A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */, A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */, A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */, A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */, A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */, A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
@@ -786,7 +853,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 17.1;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -830,7 +897,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 17.1;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -856,7 +923,7 @@
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
@@ -880,7 +947,7 @@
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
@@ -903,7 +970,7 @@
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
@@ -926,7 +993,7 @@
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 16.4;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
@@ -982,6 +1049,14 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.1;
};
};
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = { A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/techprimate/TPPDF.git"; repositoryURL = "https://github.com/techprimate/TPPDF.git";
@@ -990,14 +1065,32 @@
minimumVersion = 2.4.1; minimumVersion = 2.4.1;
}; };
}; };
A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/evgenyneu/keychain-swift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 24.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
A74D33BD2AF82AAE00D06555 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */; package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
productName = TPPDF; productName = TPPDF;
}; };
A9E78A362BEA839100206866 /* KeychainSwift */ = {
isa = XCSwiftPackageProductDependency;
package = A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
/* End XCSwiftPackageProductDependency section */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = A70171762AA8E71900064C43 /* Project object */; rootObject = A70171762AA8E71900064C43 /* Project object */;

View File

@@ -1,6 +1,24 @@
{ {
"originHash" : "314ca0b5cf5f134470eb4e9e12133500ae78d8b9a08f490e0065f2b3ceb4a25a", "originHash" : "4b59f87688d89ebd5be92449f747ea79123f0d90515aea6b92e218b4860a3ef3",
"pins" : [ "pins" : [
{
"identity" : "keychain-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/evgenyneu/keychain-swift.git",
"state" : {
"revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608",
"version" : "24.0.0"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
"version" : "2.6.1"
}
},
{ {
"identity" : "tppdf", "identity" : "tppdf",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -0,0 +1,45 @@
//
// AccountManager.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.01.25.
//
import Foundation
@Observable
class AccountManager {
var accounts: [any Account] = []
var authTokens: [String: String] = [:]
/// Save account as JSON.
func save(account: any Account, authToken: String?) async throws {
account.saveTokenToKeychain(authToken!)
let data = try JSONEncoder().encode(account)
let accountDir = account.accountType.rawValue + "/" + account.id.uuidString + "/account.json"
await DataStore.shared.save(data: data, toPath: accountDir)
}
/// Load accounts from JSON files.
func loadAccounts() async throws {
// Read data from file or user defaults
for accountType in AccountType.allCases {
// List all account UUIDs under the /accountType directory
let accountUUIDs = DataStore.shared.listAllFolders(dir: accountType.rawValue + "/")
// Decode each account and fetch the authToken
for accountUUID in accountUUIDs {
do {
guard let account = try await DataStore.shared.loadDynamic(fromPath: accountType.rawValue + "/" + accountUUID + "/account.json", type: accountType.accountType) else {
continue
}
authTokens[accountUUID] = (account as! any Account).getTokenFromKeychain() ?? ""
self.accounts.append(account as! (any Account))
} catch {
continue
}
}
}
}
}

View File

@@ -0,0 +1,49 @@
//
// AccountProtocol.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.01.25.
//
import Foundation
import SwiftUI
import KeychainSwift
enum AccountType: String, Codable, CaseIterable {
case cookbook = "cookbook"
case local = "local"
var accountType: any Decodable.Type {
switch self {
case .cookbook: return CookbookAccount.self
case .local: return LocalAccount.self
}
}
}
protocol Account: Codable, Identifiable {
/// A unique identifier for this account
var id: UUID { get }
/// A name for the account that can be displayed in the UI
var displayName: String { get }
/// For differentiating account types when decoding
var accountType: AccountType { get }
/// Base endpoint URL
var baseURL: URL { get }
/// Account username
var username: String { get }
/// For storing/retrieving tokens from Keychain
func saveTokenToKeychain(_ token: String)
func getTokenFromKeychain() -> String?
}

View File

@@ -0,0 +1,31 @@
//
// CookbookAccount.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 24.01.25.
//
import Foundation
import KeychainSwift
struct CookbookAccount: Account {
let accountType: AccountType = .cookbook
let id: UUID
var displayName: String = "Nextcloud Cookbook Account"
let baseURL: URL
let username: String
/// Keychain convenience
func saveTokenToKeychain(_ token: String) {
let keychain = KeychainSwift()
keychain.set(token, forKey: "token-\(id.uuidString)")
}
func getTokenFromKeychain() -> String? {
let keychain = KeychainSwift()
return keychain.get("token-\(id.uuidString)")
}
}

View File

@@ -0,0 +1,28 @@
//
// LocalAccount.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.04.25.
//
import Foundation
struct LocalAccount: Account {
let id: UUID
var displayName: String = "Local Account"
var accountType: AccountType = .local
let baseURL: URL = URL(filePath: "")!
let username: String = ""
/// Keychain convenience
func saveTokenToKeychain(_ token: String) {
return
}
func getTokenFromKeychain() -> String? {
return nil
}
}

View File

@@ -6,89 +6,114 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import UIKit import UIKit
@Observable class AppState {
}
/*
@MainActor class AppState: ObservableObject { @MainActor class AppState: ObservableObject {
@Published var categories: [Category] = [] @Published var categories: [Category] = []
@Published var recipes: [String: [Recipe]] = [:] @Published var recipes: [String: [CookbookApiRecipeV1]] = [:]
@Published var recipeDetails: [Int: RecipeDetail] = [:] @Published var recipeDetails: [Int: CookbookApiRecipeDetailV1] = [:]
@Published var timers: [String: RecipeTimer] = [:] @Published var timers: [String: RecipeTimer] = [:]
var recipeImages: [Int: [String: UIImage]] = [:] var recipeImages: [Int: [String: UIImage]] = [:]
var imagesNeedUpdate: [Int: [String: Bool]] = [:] var imagesNeedUpdate: [Int: [String: Bool]] = [:]
var lastUpdates: [String: Date] = [:] var lastUpdates: [String: Date] = [:]
var allKeywords: [RecipeKeyword] = [] var allKeywords: [RecipeKeyword] = []
private let dataStore: DataStore private let dataStore: DataStore
private let api: CookbookApiProtocol
init() {
init(api: CookbookApiProtocol? = nil) { print("Created MainViewModel")
Logger.network.debug("Created AppState")
self.dataStore = DataStore() self.dataStore = DataStore()
self.api = api ?? CookbookApiFactory.makeClient()
if UserSettings.shared.authString == "" { if UserSettings.shared.authString == "" {
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
let loginData = loginString.data(using: String.Encoding.utf8)! let loginData = loginString.data(using: String.Encoding.utf8)!
UserSettings.shared.authString = loginData.base64EncodedString() UserSettings.shared.authString = loginData.base64EncodedString()
} }
} }
enum FetchMode { enum FetchMode {
case preferLocal, preferServer, onlyLocal, onlyServer case preferLocal, preferServer, onlyLocal, onlyServer
} }
/**
Asynchronously loads and updates the list of categories.
This function attempts to fetch the list of categories from the server. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it attempts to load the categories from local storage.
// MARK: - Categories - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
*/
func getCategories() async { func getCategories() async {
do { let (categories, _) = await cookbookApi.getCategories(
let categories = try await api.getCategories() auth: UserSettings.shared.authString
Logger.data.debug("Successfully loaded categories") )
if let categories = categories {
print("Successfully loaded categories")
self.categories = categories self.categories = categories
await saveLocal(self.categories, path: "categories.data") await saveLocal(self.categories, path: "categories.data")
} catch { } else {
Logger.data.debug("Loading categories from store ...") // If there's no server connection, try loading categories from local storage
print("Loading categories from store ...")
if let categories: [Category] = await loadLocal(path: "categories.data") { if let categories: [Category] = await loadLocal(path: "categories.data") {
self.categories = categories self.categories = categories
Logger.data.debug("Loaded categories from local store") print("Success!")
} else { } else {
Logger.data.error("Failed to load categories from local store") print("Failure!")
} }
} }
// Initialize the lastUpdates with distantPast dates, so that each recipeDetail is updated on launch for all categories
for category in self.categories { for category in self.categories {
lastUpdates[category.name] = Date.distantPast lastUpdates[category.name] = Date.distantPast
} }
} }
/**
Fetches recipes for a specified category from either the server or local storage.
- Parameters:
- name: The name of the category. Use "*" to fetch recipes without assigned categories.
- needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first.
This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage.
- Note: The category name "*" is used for all uncategorized recipes.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
*/
func getCategory(named name: String, fetchMode: FetchMode) async { func getCategory(named name: String, fetchMode: FetchMode) async {
Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))") print("getCategory(\(name), fetchMode: \(fetchMode))")
func getLocal() async -> Bool { func getLocal() async -> Bool {
let categoryString = name == "*" ? "_" : name if let recipes: [CookbookApiRecipeV1] = await loadLocal(path: "category_\(categoryString).data") {
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
self.recipes[name] = recipes self.recipes[name] = recipes
return true return true
} }
return false return false
} }
func getServer(store: Bool = false) async -> Bool { func getServer(store: Bool = false) async -> Bool {
let categoryString = name == "*" ? "_" : name let (recipes, _) = await cookbookApi.getCategory(
do { auth: UserSettings.shared.authString,
let recipes = try await api.getCategory(named: categoryString) named: categoryString
)
if let recipes = recipes {
self.recipes[name] = recipes self.recipes[name] = recipes
if store { if store {
await saveLocal(recipes, path: "category_\(categoryString).data") await saveLocal(recipes, path: "category_\(categoryString).data")
} }
//userSettings.lastUpdate = Date()
return true return true
} catch {
return false
} }
return false
} }
let categoryString = name == "*" ? "_" : name
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if await getLocal() { return } if await getLocal() { return }
@@ -102,68 +127,99 @@ import UIKit
if await getServer() { return } if await getServer() { return }
} }
} }
// MARK: - Recipe details
func updateAllRecipeDetails() async { func updateAllRecipeDetails() async {
for category in self.categories { for category in self.categories {
await updateRecipeDetails(in: category.name) await updateRecipeDetails(in: category.name)
} }
UserSettings.shared.lastUpdate = Date() UserSettings.shared.lastUpdate = Date()
} }
func updateRecipeDetails(in category: String) async { func updateRecipeDetails(in category: String) async {
guard UserSettings.shared.storeRecipes else { return } guard UserSettings.shared.storeRecipes else { return }
guard let recipes = self.recipes[category] else { return } guard let recipes = self.recipes[category] else { return }
for recipe in recipes { for recipe in recipes {
if let dateModified = recipe.dateModified { if let dateModified = recipe.dateModified {
if needsUpdate(category: category, lastModified: dateModified) { if needsUpdate(category: category, lastModified: dateModified) {
Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))") print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)")
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
} else { } else {
Logger.data.debug("\(recipe.name) is up to date.") print("\(recipe.name) is up to date.")
} }
} else { } else {
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
} }
} }
} }
/**
Asynchronously retrieves all recipes either from the server or the locally cached data.
func getRecipes() async -> [Recipe] { This function attempts to fetch all recipes from the server using the provided `api`. If the server connection is successful, it returns the fetched recipes. If the server connection fails, it falls back to combining locally cached recipes from different categories.
do {
return try await api.getRecipes() - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance, and categories have been previously loaded.
} catch {
Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)") Example usage:
```swift
let recipes = await mainViewModel.getRecipes()
*/
func getRecipes() async -> [CookbookApiRecipeV1] {
let (recipes, error) = await cookbookApi.getRecipes(
auth: UserSettings.shared.authString
)
if let recipes = recipes {
return recipes
} else if let error = error {
print(error)
} }
var allRecipes: [Recipe] = [] var allRecipes: [CookbookApiRecipeV1] = []
for category in categories { for category in categories {
if let recipeArray = self.recipes[category.name] { if let recipeArray = self.recipes[category.name] {
allRecipes.append(contentsOf: recipeArray) allRecipes.append(contentsOf: recipeArray)
} }
} }
return allRecipes.sorted(by: { $0.name < $1.name }) return allRecipes.sorted(by: {
$0.name < $1.name
})
} }
/**
Asynchronously retrieves a recipe detail either from the server or locally cached data.
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? { This function attempts to fetch a recipe detail with the specified `id` from the server using the provided `api`. If the server connection is successful, it returns the fetched recipe detail. If the server connection fails, it falls back to loading the recipe detail from local storage.
func getLocal() async -> RecipeDetail? {
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe to retrieve.
Example usage:
```swift
let recipeDetail = await mainViewModel.getRecipe(id: 123)
*/
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> CookbookApiRecipeDetailV1? {
func getLocal() async -> CookbookApiRecipeDetailV1? {
if let recipe: CookbookApiRecipeDetailV1 = await loadLocal(path: "recipe\(id).data") { return recipe }
return nil return nil
} }
func getServer() async -> RecipeDetail? { func getServer() async -> CookbookApiRecipeDetailV1? {
do { let (recipe, error) = await cookbookApi.getRecipe(
let recipe = try await api.getRecipe(id: id) auth: UserSettings.shared.authString,
id: id
)
if let recipe = recipe {
if save { if save {
self.recipeDetails[id] = recipe self.recipeDetails[id] = recipe
await self.saveLocal(recipe, path: "recipe\(id).data") await self.saveLocal(recipe, path: "recipe\(id).data")
} }
return recipe return recipe
} catch { } else if let error = error {
Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)") print(error)
return nil
} }
return nil
} }
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if let recipe = await getLocal() { return recipe } if let recipe = await getLocal() { return recipe }
@@ -178,19 +234,31 @@ import UIKit
} }
return nil return nil
} }
/**
Asynchronously downloads and saves details, thumbnails, and full images for all recipes.
This function iterates through all loaded categories, fetches and updates the recipes from the server, and then downloads and saves details, thumbnails, and full images for each recipe.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
await mainViewModel.downloadAllRecipes()
*/
func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async { func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async {
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) { if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
await saveLocal(recipeDetail, path: "recipe\(id).data") await saveLocal(recipeDetail, path: "recipe\(id).data")
} }
if withThumb { if withThumb {
let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer) let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer)
guard let thumbnail = thumbnail else { return } guard let thumbnail = thumbnail else { return }
guard let thumbnailData = thumbnail.pngData() else { return } guard let thumbnailData = thumbnail.pngData() else { return }
await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb") await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb")
} }
if withImage { if withImage {
let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer) let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer)
guard let image = image else { return } guard let image = image else { return }
@@ -198,26 +266,50 @@ import UIKit
await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full") await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full")
} }
} }
/// Check if recipeDetail is stored locally, either in cache or on disk
/// - Parameters
/// - recipeId: The id of a recipe.
/// - Returns: True if the recipeDetail is stored, otherwise false
func recipeDetailExists(recipeId: Int) -> Bool { func recipeDetailExists(recipeId: Int) -> Bool {
return dataStore.recipeDetailExists(recipeId: recipeId) if (dataStore.recipeDetailExists(recipeId: recipeId)) {
return true
}
return false
} }
/**
Asynchronously retrieves and returns an image for a recipe with the specified ID and size.
// MARK: - Images This function attempts to fetch an image for a recipe with the specified `id` and `size` from the server using the provided `api`. If the server connection is successful, it returns the fetched image. If the server connection fails or `needsUpdate` is false, it attempts to load the image from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe associated with the image.
- size: The size of the desired image (thumbnail or full).
- needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage.
Example usage:
```swift
let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true)
*/
func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? { func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
func getLocal() async -> UIImage? { func getLocal() async -> UIImage? {
return await imageFromStore(id: id, size: size) return await imageFromStore(id: id, size: size)
} }
func getServer() async -> UIImage? { func getServer() async -> UIImage? {
do { let (image, _) = await cookbookApi.getImage(
return try await api.getImage(id: id, size: size) auth: UserSettings.shared.authString,
} catch { id: id,
return nil size: size
} )
if let image = image { return image }
return nil
} }
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if let image = imageFromCache(id: id, size: size) { if let image = imageFromCache(id: id, size: size) {
@@ -267,20 +359,28 @@ import UIKit
imagesNeedUpdate[id] = [size.rawValue: false] imagesNeedUpdate[id] = [size.rawValue: false]
return nil return nil
} }
/**
Asynchronously retrieves and returns a list of keywords (tags).
// MARK: - Keywords This function attempts to fetch a list of keywords from the server using the provided `api`. If the server connection is successful, it returns the fetched keywords. If the server connection fails, it attempts to load the keywords from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
let keywords = await mainViewModel.getKeywords()
*/
func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] { func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
func getLocal() async -> [RecipeKeyword]? { func getLocal() async -> [RecipeKeyword]? {
return await loadLocal(path: "keywords.data") return await loadLocal(path: "keywords.data")
} }
func getServer() async -> [RecipeKeyword]? { func getServer() async -> [RecipeKeyword]? {
do { let (tags, _) = await cookbookApi.getTags(
return try await api.getTags() auth: UserSettings.shared.authString
} catch { )
return nil return tags
}
} }
switch fetchMode { switch fetchMode {
@@ -303,9 +403,7 @@ import UIKit
} }
return [] return []
} }
// MARK: - Data management
func deleteAllData() { func deleteAllData() {
if dataStore.clearAll() { if dataStore.clearAll() {
self.categories = [] self.categories = []
@@ -315,14 +413,31 @@ import UIKit
self.imagesNeedUpdate = [:] self.imagesNeedUpdate = [:]
} }
} }
/**
Asynchronously deletes a recipe with the specified ID from the server and local storage.
This function attempts to delete a recipe with the specified `id` from the server using the provided `api`. If the server connection is successful, it proceeds to delete the local copy of the recipe and its details. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe to delete.
- categoryName: The name of the category to which the recipe belongs.
Example usage:
```swift
let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts")
*/
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
do { let (error) = await cookbookApi.deleteRecipe(
try await api.deleteRecipe(id: id) auth: UserSettings.shared.authString,
} catch { id: id
)
if let error = error {
return .REQUEST_DROPPED return .REQUEST_DROPPED
} }
let path = "recipe\(id).data" let path = "recipe\(id).data"
dataStore.delete(path: path) dataStore.delete(path: path)
if recipes[categoryName] != nil { if recipes[categoryName] != nil {
@@ -333,59 +448,95 @@ import UIKit
} }
return nil return nil
} }
/**
Asynchronously checks the server connection by attempting to fetch categories.
This function attempts to fetch categories from the server using the provided `api` to check the server connection status. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it returns `false`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
let isConnected = await mainViewModel.checkServerConnection()
*/
func checkServerConnection() async -> Bool { func checkServerConnection() async -> Bool {
do { let (categories, _) = await cookbookApi.getCategories(
let categories = try await api.getCategories() auth: UserSettings.shared.authString
)
if let categories = categories {
self.categories = categories self.categories = categories
await saveLocal(categories, path: "categories.data") await saveLocal(categories, path: "categories.data")
return true return true
} catch {
return false
} }
return false
} }
/**
Asynchronously uploads a recipe to the server.
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) { This function attempts to create or update a recipe on the server using the provided `api`. If the server connection is successful, it uploads the provided `recipeDetail`. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
do {
if createNew { - Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
let id = try await api.createRecipe(recipeDetail)
return (id, nil) - Parameters:
} else { - recipeDetail: The detailed information of the recipe to upload.
let id = try await api.updateRecipe(recipeDetail) - createNew: If true, creates a new recipe on the server; otherwise, updates an existing one.
return (id, nil)
} Example usage:
} catch { ```swift
return (nil, .REQUEST_DROPPED) let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
} */
} func uploadRecipe(recipeDetail: CookbookApiRecipeDetailV1, createNew: Bool) async -> RequestAlert? {
var error: NetworkError? = nil
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) { if createNew {
do { error = await cookbookApi.createRecipe(
let recipeDetail = try await api.importRecipe(url: url) auth: UserSettings.shared.authString,
return (recipeDetail, nil) recipe: recipeDetail
} catch { )
} else {
error = await cookbookApi.updateRecipe(
auth: UserSettings.shared.authString,
recipe: recipeDetail
)
}
if error != nil {
return .REQUEST_DROPPED
}
return nil
}
func importRecipe(url: String) async -> (CookbookApiRecipeDetailV1?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
let (recipeDetail, error) = await cookbookApi.importRecipe(
auth: UserSettings.shared.authString,
data: data
)
if error != nil {
return (nil, .REQUEST_DROPPED) return (nil, .REQUEST_DROPPED)
} }
return (recipeDetail, nil)
} }
} }
// MARK: - Local storage helpers
extension AppState { extension AppState {
func loadLocal<T: Codable>(path: String) async -> T? { func loadLocal<T: Codable>(path: String) async -> T? {
do { do {
return try await dataStore.load(fromPath: path) return try await dataStore.load(fromPath: path)
} catch { } catch (let error) {
Logger.data.debug("Failed to load local data: \(error.localizedDescription)") print(error)
return nil return nil
} }
} }
func saveLocal<T: Codable>(_ object: T, path: String) async { func saveLocal<T: Codable>(_ object: T, path: String) async {
await dataStore.save(data: object, toPath: path) await dataStore.save(data: object, toPath: path)
} }
private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? { private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? {
do { do {
let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")" let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")"
@@ -395,18 +546,18 @@ extension AppState {
return image return image
} }
} catch { } catch {
Logger.data.debug("Could not find image in local storage.") print("Could not find image in local storage.")
return nil return nil
} }
return nil return nil
} }
private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async { private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async {
if let data = image.pngData() { if let data = image.pngData() {
await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)") await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)")
} }
} }
private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) { private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) {
if recipeImages[id] != nil { if recipeImages[id] != nil {
recipeImages[id]![size.rawValue] = image recipeImages[id]![size.rawValue] = image
@@ -419,14 +570,14 @@ extension AppState {
imagesNeedUpdate[id] = [size.rawValue: false] imagesNeedUpdate[id] = [size.rawValue: false]
} }
} }
private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? { private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? {
if recipeImages[id] != nil { if recipeImages[id] != nil {
return recipeImages[id]![size.rawValue] return recipeImages[id]![size.rawValue]
} }
return nil return nil
} }
private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool { private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool {
if imagesNeedUpdate[id] != nil { if imagesNeedUpdate[id] != nil {
if imagesNeedUpdate[id]![size.rawValue] != nil { if imagesNeedUpdate[id]![size.rawValue] != nil {
@@ -435,22 +586,26 @@ extension AppState {
} }
return true return true
} }
private func needsUpdate(category: String, lastModified: String) -> Bool { private func needsUpdate(category: String, lastModified: String) -> Bool {
print("=======================")
print("original date string: \(lastModified)")
// Create a DateFormatter
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
// Convert the string to a Date object
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] { if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
if date < lastUpdate { if date < lastUpdate {
Logger.data.debug("No update needed for \(category)") print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
return false return false
} else { } else {
Logger.data.debug("Update needed for \(category)") print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
return true return true
} }
} }
Logger.data.debug("Date parse failed, update needed for \(category)") print("String is not a date. Update needed.")
return true return true
} }
} }
@@ -473,12 +628,13 @@ extension AppState {
timers[recipeId] = timer timers[recipeId] = timer
return timer return timer
} }
func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer { func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer {
return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration) return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration)
} }
func deleteTimer(forRecipe recipeId: String) { func deleteTimer(forRecipe recipeId: String) {
timers.removeValue(forKey: recipeId) timers.removeValue(forKey: recipeId)
} }
} }
*/

View File

@@ -0,0 +1,44 @@
//
// AuthManager.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 30.05.25.
//
import Foundation
import KeychainSwift
class AuthManager {
static let shared = AuthManager()
let keychain = KeychainSwift()
var authString: String? = nil
private let nextcloudUsernameKey = "nextcloud_username"
private let nextcloudAuthStringKey = "nextcloud_auth_string" // Stored as base64
func saveNextcloudCredentials(username: String, appPassword: String) {
keychain.set(username, forKey: nextcloudUsernameKey)
let loginString = "\(username):\(appPassword)"
if let loginData = loginString.data(using: .utf8) {
keychain.set(loginData.base64EncodedString(), forKey: nextcloudAuthStringKey)
}
}
func getNextcloudCredentials() -> (username: String?, authString: String?) {
let username = keychain.get(nextcloudUsernameKey)
let authString = keychain.get(nextcloudAuthStringKey)
return (username, authString)
}
func loadAuthString() {
authString = keychain.get(nextcloudAuthStringKey)
}
func deleteNextcloudCredentials() {
keychain.delete(nextcloudUsernameKey)
keychain.delete(nextcloudAuthStringKey)
}
}

View File

@@ -6,13 +6,12 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
class DataStore { class DataStore {
let fileManager = FileManager.default let fileManager = FileManager.default
static let shared = DataStore() static let shared = DataStore()
private static func fileURL(appending: String) throws -> URL { private static func fileURL(appending: String) throws -> URL {
try FileManager.default.url( try FileManager.default.url(
for: .documentDirectory, for: .documentDirectory,
@@ -22,7 +21,7 @@ class DataStore {
) )
.appendingPathComponent(appending) .appendingPathComponent(appending)
} }
private static func fileURL() throws -> URL { private static func fileURL() throws -> URL {
try FileManager.default.url( try FileManager.default.url(
for: .documentDirectory, for: .documentDirectory,
@@ -31,19 +30,28 @@ class DataStore {
create: false create: false
) )
} }
func load<D: Decodable>(fromPath path: String) async throws -> D? { func load<D: Decodable>(fromPath path: String) async throws -> D? {
let task = Task<D?, Error> { let task = Task<D?, Error> {
let fileURL = try Self.fileURL(appending: path) let fileURL = try Self.fileURL(appending: path)
guard let data = try? Data(contentsOf: fileURL) else { guard let data = try? Data(contentsOf: fileURL) else {
return nil return nil
} }
let storedRecipes = try JSONDecoder().decode(D.self, from: data) let decodedData = try JSONDecoder().decode(D.self, from: data)
return storedRecipes return decodedData
} }
return try await task.value return try await task.value
} }
func loadDynamic(fromPath path: String, type: Decodable.Type) async throws -> Any? {
let fileURL = try Self.fileURL(appending: path)
guard let data = try? Data(contentsOf: fileURL) else {
return nil
}
let decoded = try JSONDecoder().decode(type, from: data)
return decoded
}
func save<D: Encodable>(data: D, toPath path: String) async { func save<D: Encodable>(data: D, toPath path: String) async {
let task = Task { let task = Task {
let data = try JSONEncoder().encode(data) let data = try JSONEncoder().encode(data)
@@ -53,36 +61,63 @@ class DataStore {
do { do {
_ = try await task.value _ = try await task.value
} catch { } catch {
Logger.data.error("Could not save data (path: \(path))") print("Could not save data (path: \(path)")
} }
} }
func delete(path: String) { func delete(path: String) {
Task { Task {
let fileURL = try Self.fileURL(appending: path) let fileURL = try Self.fileURL(appending: path)
try fileManager.removeItem(at: fileURL) try fileManager.removeItem(at: fileURL)
} }
} }
func recipeDetailExists(recipeId: Int) -> Bool { func recipeDetailExists(recipeId: Int) -> Bool {
let filePath = "recipe\(recipeId).data" let filePath = "recipe\(recipeId).data"
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false } guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
return fileManager.fileExists(atPath: folderPath + filePath) return fileManager.fileExists(atPath: folderPath + filePath)
} }
func listAllFolders(dir: String) -> [String] {
guard let baseURL = try? Self.fileURL() else {
print("Failed to retrieve documents directory.")
return []
}
let targetURL = baseURL.appendingPathComponent(dir)
do {
let contents = try fileManager.contentsOfDirectory(at: targetURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
let folders = contents.filter { url in
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
return folders.map { $0.lastPathComponent }
} catch {
print("Error listing folders in \(dir): \(error)")
return []
}
}
func clearAll() -> Bool { func clearAll() -> Bool {
Logger.data.debug("Attempting to delete all data ...") print("Attempting to delete all data ...")
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false } guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
print("Folder path: ", folderPath)
do { do {
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath) let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
for filePath in filePaths { for filePath in filePaths {
print("File path: ", filePath)
try fileManager.removeItem(atPath: folderPath + filePath) try fileManager.removeItem(atPath: folderPath + filePath)
} }
} catch { } catch {
Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)") print("Could not delete documents folder contents: \(error)")
return false return false
} }
Logger.data.debug("All data deleted successfully.") print("Done.")
return true return true
} }
} }

View File

@@ -6,20 +6,168 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import SwiftData
class ObservableRecipeDetail: ObservableObject { // MARK: - Recipe Model
@Model
class RecipeImage {
enum RecipeImageSize {
case THUMB, FULL
}
var imageData: Data?
@Transient
var image: UIImage? {
guard let imageData else { return nil }
return UIImage(data: imageData)
}
init(imageData: Data? = nil) {
self.imageData = imageData
}
}
@Model
class RecipeThumbnail {
var thumbnailData: Data?
@Transient
var thumbnail: UIImage? {
guard let thumbnailData else { return nil }
return UIImage(data: thumbnailData)
}
init(thumbnailData: Data? = nil) {
self.thumbnailData = thumbnailData
}
}
@Model
class Recipe {
var id: String
var name: String
var keywords: [String]
@Attribute(.externalStorage) var image: RecipeImage?
@Attribute(.externalStorage) var thumbnail: RecipeThumbnail?
var dateCreated: String? = nil
var dateModified: String? = nil
var prepTime: String
var cookTime: String
var totalTime: String
var recipeDescription: String
var url: String?
var yield: Int
var category: String
var tools: [String]
var ingredients: [String]
var instructions: [String]
var nutrition: [String:String]
// Additional functionality
@Transient
var ingredientMultiplier: Double = 1.0
var prepTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(prepTime)
}
var cookTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(cookTime)
}
var totalTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(totalTime)
}
init(
id: String,
name: String,
keywords: [String],
dateCreated: String? = nil,
dateModified: String? = nil,
prepTime: String,
cookTime: String,
totalTime: String,
recipeDescription: String,
url: String? = nil,
yield: Int,
category: String,
tools: [String],
ingredients: [String],
instructions: [String],
nutrition: [String : String],
ingredientMultiplier: Double
) {
self.id = id
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.recipeDescription = recipeDescription
self.url = url
self.yield = yield
self.category = category
self.tools = tools
self.ingredients = ingredients
self.instructions = instructions
self.nutrition = nutrition
self.ingredientMultiplier = ingredientMultiplier
}
init() {
self.id = UUID().uuidString
self.name = String(localized: "New Recipe")
self.keywords = []
self.dateCreated = nil
self.dateModified = nil
self.prepTime = "0"
self.cookTime = "0"
self.totalTime = "0"
self.recipeDescription = ""
self.url = ""
self.yield = 1
self.category = ""
self.tools = []
self.ingredients = []
self.instructions = []
self.nutrition = [:]
self.ingredientMultiplier = 1
}
}
// MARK: - Recipe Stub
struct RecipeStub: Codable, Hashable, Identifiable {
let id: String
let name: String
let keywords: String?
let dateCreated: String?
let dateModified: String?
let thumbnailPath: String?
var storedLocally: Bool = false
var lastUpdated: String?
}
// MARK: - Recipe
/*
class Recipe: ObservableObject {
// Cookbook recipe detail fields // Cookbook recipe detail fields
var id: String var id: String
@Published var name: String @Published var name: String
@Published var keywords: [String] @Published var keywords: [String]
@Published var imageUrl: String @Published var imageUrl: String?
var dateCreated: String? = nil
var dateModified: String? = nil
@Published var prepTime: DurationComponents @Published var prepTime: DurationComponents
@Published var cookTime: DurationComponents @Published var cookTime: DurationComponents
@Published var totalTime: DurationComponents @Published var totalTime: DurationComponents
@Published var description: String @Published var description: String
@Published var url: String @Published var url: String?
@Published var recipeYield: Int @Published var recipeYield: Int
@Published var recipeCategory: String @Published var recipeCategory: String
@Published var tool: [String] @Published var tool: [String]
@@ -52,7 +200,7 @@ class ObservableRecipeDetail: ObservableObject {
ingredientMultiplier = 1 ingredientMultiplier = 1
} }
init(_ recipeDetail: RecipeDetail) { init(_ recipeDetail: CookbookApiRecipeDetailV1) {
id = recipeDetail.id id = recipeDetail.id
name = recipeDetail.name name = recipeDetail.name
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",") keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
@@ -72,8 +220,70 @@ class ObservableRecipeDetail: ObservableObject {
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield) ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
} }
func toRecipeDetail() -> RecipeDetail { init(
return RecipeDetail( name: String,
keywords: [String],
dateCreated: String?,
dateModified: String?,
imageUrl: String?,
id: String,
prepTime: DurationComponents? = nil,
cookTime: DurationComponents? = nil,
totalTime: DurationComponents? = nil,
description: String,
url: String?,
recipeYield: Int,
recipeCategory: String,
tool: [String],
recipeIngredient: [String],
recipeInstructions: [String],
nutrition: [String:String]
) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime ?? DurationComponents()
self.cookTime = cookTime ?? DurationComponents()
self.totalTime = totalTime ?? DurationComponents()
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
ingredientMultiplier = Double(recipeYield == 0 ? 1 : recipeYield)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
_name = Published(initialValue: try container.decode(String.self, forKey: .name))
_keywords = Published(initialValue: try container.decode([String].self, forKey: .keywords))
_imageUrl = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .imageUrl))
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
_prepTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .prepTime))
_cookTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .cookTime))
_totalTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .totalTime))
_description = Published(initialValue: try container.decode(String.self, forKey: .description))
_url = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .url))
_recipeYield = Published(initialValue: try container.decode(Int.self, forKey: .recipeYield))
_recipeCategory = Published(initialValue: try container.decode(String.self, forKey: .recipeCategory))
_tool = Published(initialValue: try container.decode([String].self, forKey: .tool))
_recipeIngredient = Published(initialValue: try container.decode([String].self, forKey: .recipeIngredient))
_recipeInstructions = Published(initialValue: try container.decode([String].self, forKey: .recipeInstructions))
_nutrition = Published(initialValue: try container.decode([String: String].self, forKey: .nutrition))
_ingredientMultiplier = Published(initialValue: try container.decode(Double.self, forKey: .ingredientMultiplier))
}
func toRecipeDetail() -> CookbookApiRecipeDetailV1 {
return CookbookApiRecipeDetailV1(
name: self.name, name: self.name,
keywords: self.keywords.joined(separator: ","), keywords: self.keywords.joined(separator: ","),
dateCreated: "", dateCreated: "",
@@ -99,14 +309,14 @@ class ObservableRecipeDetail: ObservableObject {
return AttributedString(ingredient) return AttributedString(ingredient)
} }
// Match mixed fractions first // Match mixed fractions first
var matches = ObservableRecipeDetail.matchPatternAndMultiply( var matches = Recipe.matchPatternAndMultiply(
.mixedFraction, .mixedFraction,
in: ingredient, in: ingredient,
multFactor: factor multFactor: factor
) )
// Then match fractions, exclude mixed fraction ranges // Then match fractions, exclude mixed fraction ranges
matches.append(contentsOf: matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply( Recipe.matchPatternAndMultiply(
.fraction, .fraction,
in: ingredient, in: ingredient,
multFactor: factor, multFactor: factor,
@@ -115,7 +325,7 @@ class ObservableRecipeDetail: ObservableObject {
) )
// Match numbers at last, exclude all prior matches // Match numbers at last, exclude all prior matches
matches.append(contentsOf: matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply( Recipe.matchPatternAndMultiply(
.number, .number,
in: ingredient, in: ingredient,
multFactor: factor, multFactor: factor,
@@ -181,7 +391,7 @@ class ObservableRecipeDetail: ObservableObject {
} }
return foundMatches return foundMatches
} catch { } catch {
Logger.data.error("Regex error: \(error.localizedDescription)") print("Regex error: \(error.localizedDescription)")
} }
return [] return []
} }
@@ -222,6 +432,34 @@ class ObservableRecipeDetail: ObservableObject {
} }
} }
extension Recipe: Codable {
enum CodingKeys: String, CodingKey {
case id, name, keywords, imageUrl, dateCreated, dateModified, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition, ingredientMultiplier
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(keywords, forKey: .keywords)
try container.encode(imageUrl, forKey: .imageUrl)
try container.encode(dateCreated, forKey: .dateCreated)
try container.encode(dateModified, forKey: .dateModified)
try container.encode(prepTime, forKey: .prepTime)
try container.encode(cookTime, forKey: .cookTime)
try container.encode(totalTime, forKey: .totalTime)
try container.encode(description, forKey: .description)
try container.encode(url, forKey: .url)
try container.encode(recipeYield, forKey: .recipeYield)
try container.encode(recipeCategory, forKey: .recipeCategory)
try container.encode(tool, forKey: .tool)
try container.encode(recipeIngredient, forKey: .recipeIngredient)
try container.encode(recipeInstructions, forKey: .recipeInstructions)
try container.encode(nutrition, forKey: .nutrition)
try container.encode(ingredientMultiplier, forKey: .ingredientMultiplier)
}
}
enum RegexPattern: String, CaseIterable, Identifiable { enum RegexPattern: String, CaseIterable, Identifiable {
case mixedFraction, fraction, number case mixedFraction, fraction, number
@@ -250,3 +488,4 @@ enum RegexPattern: String, CaseIterable, Identifiable {
} }
} }
*/

View File

@@ -6,15 +6,14 @@
// //
import Foundation import Foundation
import OSLog
extension JSONDecoder { extension JSONDecoder {
static func safeDecode<T: Decodable>(_ data: Data) -> T? { static func safeDecode<T: Decodable>(_ data: Data) -> T? {
let decoder = JSONDecoder() let decoder = JSONDecoder()
do { do {
return try decoder.decode(T.self, from: data) return try decoder.decode(T.self, from: data)
} catch { } catch (let error) {
Logger.data.error("JSONDecoder - safeDecode(): \(error.localizedDescription)") print(error)
return nil return nil
} }
} }
@@ -25,7 +24,7 @@ extension JSONEncoder {
do { do {
return try JSONEncoder().encode(object) return try JSONEncoder().encode(object)
} catch { } catch {
Logger.data.error("JSONEncoder - safeEncode(): Could not encode \(String(describing: T.self))") print("JSONDecoder - safeEncode(): Could not encode object \(T.self)")
} }
return nil return nil
} }

View File

@@ -16,7 +16,4 @@ extension Logger {
/// Network related logging /// Network related logging
static let network = Logger(subsystem: subsystem, category: "network") static let network = Logger(subsystem: subsystem, category: "network")
/// Data/persistence related logging
static let data = Logger(subsystem: subsystem, category: "data")
} }

View File

@@ -69,6 +69,7 @@
} }
}, },
"(%lld)" : { "(%lld)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -285,6 +286,7 @@
} }
}, },
"%lld Serving(s)" : { "%lld Serving(s)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -485,7 +487,6 @@
} }
}, },
"Add" : { "Add" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -552,6 +553,7 @@
} }
}, },
"Add new recipe" : { "Add new recipe" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -572,6 +574,9 @@
} }
} }
} }
},
"All Recipes" : {
}, },
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
"localizations" : { "localizations" : {
@@ -641,6 +646,7 @@
} }
}, },
"App Token Login" : { "App Token Login" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -663,7 +669,6 @@
} }
}, },
"Bad URL" : { "Bad URL" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -752,8 +757,12 @@
} }
} }
} }
},
"Categories" : {
}, },
"Category" : { "Category" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -822,6 +831,7 @@
} }
}, },
"Choose" : { "Choose" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -842,6 +852,9 @@
} }
} }
} }
},
"Client error: %lld" : {
}, },
"Comma (e.g. 1,42)" : { "Comma (e.g. 1,42)" : {
"localizations" : { "localizations" : {
@@ -910,6 +923,7 @@
} }
}, },
"Connected to server." : { "Connected to server." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -932,7 +946,6 @@
} }
}, },
"Connection error" : { "Connection error" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -955,6 +968,7 @@
} }
}, },
"Cookbook Client" : { "Cookbook Client" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -977,6 +991,7 @@
} }
}, },
"Cookbooks" : { "Cookbooks" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1041,6 +1056,9 @@
} }
} }
} }
},
"Copy Error" : {
}, },
"Copy Link" : { "Copy Link" : {
"localizations" : { "localizations" : {
@@ -1107,6 +1125,9 @@
} }
} }
} }
},
"Data decoding failed." : {
}, },
"Decimal number format" : { "Decimal number format" : {
"localizations" : { "localizations" : {
@@ -1129,6 +1150,9 @@
} }
} }
} }
},
"Decoding Error" : {
}, },
"Delete" : { "Delete" : {
"localizations" : { "localizations" : {
@@ -1306,6 +1330,9 @@
} }
} }
} }
},
"Dismiss" : {
}, },
"Done" : { "Done" : {
"localizations" : { "localizations" : {
@@ -1397,6 +1424,7 @@
} }
}, },
"e.g.: example.com" : { "e.g.: example.com" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1439,6 +1467,9 @@
} }
} }
} }
},
"Encoding Error" : {
}, },
"Error" : { "Error" : {
"localizations" : { "localizations" : {
@@ -1461,6 +1492,9 @@
} }
} }
} }
},
"Error: Login URL not available." : {
}, },
"Error." : { "Error." : {
"localizations" : { "localizations" : {
@@ -1483,6 +1517,9 @@
} }
} }
} }
},
"example.com" : {
}, },
"Expand information section" : { "Expand information section" : {
"localizations" : { "localizations" : {
@@ -1597,6 +1634,7 @@
} }
}, },
"Fraction" : { "Fraction" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1619,6 +1657,7 @@
} }
}, },
"General" : { "General" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1729,6 +1768,7 @@
} }
}, },
"If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : { "If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1793,8 +1833,12 @@
} }
} }
} }
},
"If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application." : {
}, },
"Import" : { "Import" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1817,6 +1861,7 @@
} }
}, },
"Import Recipe" : { "Import Recipe" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1971,6 +2016,12 @@
} }
} }
} }
},
"Invalid data error." : {
},
"Invalid request" : {
}, },
"Keep screen awake when viewing recipes" : { "Keep screen awake when viewing recipes" : {
"localizations" : { "localizations" : {
@@ -1995,6 +2046,7 @@
} }
}, },
"Keywords" : { "Keywords" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2061,6 +2113,7 @@
} }
}, },
"Last updated: %@" : { "Last updated: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2103,6 +2156,12 @@
} }
} }
} }
},
"Log in" : {
},
"Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed." : {
}, },
"Log out" : { "Log out" : {
"localizations" : { "localizations" : {
@@ -2171,6 +2230,7 @@
} }
}, },
"Login Method" : { "Login Method" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2193,6 +2253,7 @@
} }
}, },
"Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used." : { "Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2325,8 +2386,12 @@
} }
} }
} }
},
"Missing URL." : {
}, },
"Mixed fraction" : { "Mixed fraction" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2436,6 +2501,9 @@
} }
} }
} }
},
"Nextcloud" : {
}, },
"Nextcloud Login" : { "Nextcloud Login" : {
"localizations" : { "localizations" : {
@@ -2460,6 +2528,7 @@
} }
}, },
"No keywords." : { "No keywords." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2504,6 +2573,7 @@
} }
}, },
"None" : { "None" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2526,6 +2596,7 @@
} }
}, },
"Number" : { "Number" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2656,9 +2727,14 @@
} }
} }
} }
},
"Parameter encoding failed." : {
},
"Parameters are nil." : {
}, },
"Parsing error" : { "Parsing error" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2681,6 +2757,7 @@
} }
}, },
"Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings." : { "Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2725,7 +2802,6 @@
} }
}, },
"Please check the entered URL." : { "Please check the entered URL." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2968,8 +3044,12 @@
} }
} }
} }
},
"Redirection error" : {
}, },
"Refresh" : { "Refresh" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2992,6 +3072,7 @@
} }
}, },
"Refresh all" : { "Refresh all" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3059,6 +3140,7 @@
} }
}, },
"Search" : { "Search" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3081,6 +3163,7 @@
} }
}, },
"Search recipe" : { "Search recipe" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3103,6 +3186,7 @@
} }
}, },
"Search recipes/keywords" : { "Search recipes/keywords" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3125,6 +3209,7 @@
} }
}, },
"Select a default cookbook" : { "Select a default cookbook" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3145,8 +3230,12 @@
} }
} }
} }
},
"Select a Recipe" : {
}, },
"Select Keywords" : { "Select Keywords" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3169,6 +3258,7 @@
} }
}, },
"Selected keywords:" : { "Selected keywords:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3189,6 +3279,15 @@
} }
} }
} }
},
"Server address:" : {
},
"Server error: %lld" : {
},
"Server Protocol:" : {
}, },
"Serving size" : { "Serving size" : {
"comment" : "Serving size", "comment" : "Serving size",
@@ -3214,6 +3313,7 @@
} }
}, },
"Servings" : { "Servings" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3370,6 +3470,7 @@
} }
}, },
"Show help" : { "Show help" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3481,6 +3582,7 @@
} }
}, },
"Submit" : { "Submit" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3592,6 +3694,7 @@
} }
}, },
"Thank you for downloading" : { "Thank you for downloading" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3614,6 +3717,7 @@
} }
}, },
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : { "The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3634,6 +3738,9 @@
} }
} }
} }
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.\nIf the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
}, },
"The recipe has no image whose MIME type matches the Accept header" : { "The recipe has no image whose MIME type matches the Accept header" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -3659,6 +3766,7 @@
} }
}, },
"The selected cookbook will open on app launch by default." : { "The selected cookbook will open on app launch by default." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3681,6 +3789,7 @@
} }
}, },
"There are no recipes in this cookbook!" : { "There are no recipes in this cookbook!" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3748,6 +3857,7 @@
} }
}, },
"This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings." : { "This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3792,7 +3902,6 @@
} }
}, },
"This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." : { "This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3836,6 +3945,9 @@
} }
} }
} }
},
"To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line." : {
}, },
"Tool" : { "Tool" : {
"localizations" : { "localizations" : {
@@ -3994,6 +4106,7 @@
} }
}, },
"Unable to connect to server." : { "Unable to connect to server." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4014,9 +4127,17 @@
} }
} }
} }
},
"Unable to decode recipe data." : {
},
"Unable to encode recipe data." : {
},
"Unable to load recipe." : {
}, },
"Unable to load website content. Please check your internet connection." : { "Unable to load website content. Please check your internet connection." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4037,6 +4158,9 @@
} }
} }
} }
},
"Unable to save recipe." : {
}, },
"Unable to upload your recipe. Please check your internet connection." : { "Unable to upload your recipe. Please check your internet connection." : {
"localizations" : { "localizations" : {
@@ -4059,6 +4183,9 @@
} }
} }
} }
},
"Unknown error" : {
}, },
"Unsaturated fat content" : { "Unsaturated fat content" : {
"comment" : "Unsaturated fat content", "comment" : "Unsaturated fat content",
@@ -4151,6 +4278,7 @@
} }
}, },
"URL (e.g. example.com/recipe)" : { "URL (e.g. example.com/recipe)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4217,6 +4345,7 @@
} }
}, },
"Validate" : { "Validate" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {

View File

@@ -0,0 +1,138 @@
//
// RecipeEditViewModel.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.11.23.
//
import Foundation
import SwiftUI
/*
@MainActor class RecipeEditViewModel: ObservableObject {
@ObservedObject var mainViewModel: AppState
@Published var recipe: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1()
@Published var prepDuration: DurationComponents = DurationComponents()
@Published var cookDuration: DurationComponents = DurationComponents()
@Published var totalDuration: DurationComponents = DurationComponents()
@Published var searchText: String = ""
@Published var keywords: [String] = []
@Published var keywordSuggestions: [RecipeKeyword] = []
@Published var showImportSection: Bool = false
@Published var importURL: String = ""
var uploadNew: Bool = true
var waitingForUpload: Bool = false
init(mainViewModel: AppState, uploadNew: Bool) {
self.mainViewModel = mainViewModel
self.uploadNew = uploadNew
}
init(mainViewModel: AppState, recipeDetail: CookbookApiRecipeDetailV1, uploadNew: Bool) {
self.mainViewModel = mainViewModel
self.recipe = recipeDetail
self.uploadNew = uploadNew
}
func createRecipe() {
self.recipe.prepTime = prepDuration.toPTString()
self.recipe.cookTime = cookDuration.toPTString()
self.recipe.totalTime = totalDuration.toPTString()
self.recipe.setKeywordsFromArray(keywords)
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in mainViewModel.recipes.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
recipe.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
func uploadNewRecipe() async -> UserAlert? {
print("Uploading new recipe.")
waitingForUpload = true
createRecipe()
if let recipeValidationError = recipeValid() {
return recipeValidationError
}
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true)
}
func uploadEditedRecipe() async -> UserAlert? {
waitingForUpload = true
print("Uploading changed recipe.")
guard let recipeId = Int(recipe.id) else { return RequestAlert.REQUEST_DROPPED }
createRecipe()
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false)
}
func deleteRecipe() async -> RequestAlert? {
guard let id = Int(recipe.id) else {
return .REQUEST_DROPPED
}
return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory)
}
func prepareView() {
if let prepTime = recipe.prepTime {
prepDuration.fromPTString(prepTime)
}
if let cookTime = recipe.cookTime {
cookDuration.fromPTString(cookTime)
}
if let totalTime = recipe.totalTime {
totalDuration.fromPTString(totalTime)
}
self.keywords = recipe.getKeywordsArray()
}
func importRecipe() async -> UserAlert? {
let (scrapedRecipe, error) = await mainViewModel.importRecipe(url: importURL)
if let scrapedRecipe = scrapedRecipe {
self.recipe = scrapedRecipe
prepareView()
return nil
}
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL)
if let scrapedRecipe = scrapedRecipe {
self.recipe = scrapedRecipe
prepareView()
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
*/

View File

@@ -1,47 +0,0 @@
//
// CookbookApi.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.11.23.
//
import Foundation
import OSLog
import UIKit
/// The Cookbook API version.
enum CookbookApiVersion: String {
case v1 = "v1"
}
/// A protocol defining common API endpoints for the Cookbook API.
protocol CookbookApiProtocol {
func importRecipe(url: String) async throws -> RecipeDetail
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage?
func getRecipes() async throws -> [Recipe]
func createRecipe(_ recipe: RecipeDetail) async throws -> Int
func getRecipe(id: Int) async throws -> RecipeDetail
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int
func deleteRecipe(id: Int) async throws
func getCategories() async throws -> [Category]
func getCategory(named: String) async throws -> [Recipe]
func renameCategory(named: String, to newName: String) async throws
func getTags() async throws -> [RecipeKeyword]
func getRecipesTagged(keyword: String) async throws -> [Recipe]
func searchRecipes(query: String) async throws -> [Recipe]
}
enum CookbookApiFactory {
static func makeClient(
version: CookbookApiVersion = UserSettings.shared.cookbookApiVersion,
settings: UserSettings = .shared
) -> CookbookApiProtocol {
switch version {
case .v1:
return CookbookApiClient(settings: settings)
}
}
}

View File

@@ -1,160 +0,0 @@
//
// CookbookApiV1.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.11.23.
//
import Foundation
import OSLog
import UIKit
final class CookbookApiClient: CookbookApiProtocol {
private let basePath = "/index.php/apps/cookbook/api/v1"
private let settings: UserSettings
private struct RecipeImportRequest: Codable {
let url: String
}
init(settings: UserSettings = .shared) {
self.settings = settings
}
// MARK: - Private helpers
private var auth: String { settings.authString }
private func makeRequest(
path: String,
method: RequestMethod,
accept: ContentType = .JSON,
contentType: ContentType? = nil,
body: Data? = nil
) -> ApiRequest {
var headers = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: accept)
]
if let contentType = contentType {
headers.append(HeaderField.contentType(value: contentType))
}
return ApiRequest(
path: basePath + path,
method: method,
authString: auth,
headerFields: headers,
body: body
)
}
private func sendAndDecode<T: Decodable>(_ request: ApiRequest) async throws -> T {
let (data, error) = await request.send()
if let error = error { throw error }
guard let data = data else { throw NetworkError.unknownError(detail: "No data received") }
guard let decoded: T = JSONDecoder.safeDecode(data) else {
throw NetworkError.decodingFailed(detail: "Failed to decode \(T.self)")
}
return decoded
}
private func sendRaw(_ request: ApiRequest) async throws -> Data {
let (data, error) = await request.send()
if let error = error { throw error }
guard let data = data else { throw NetworkError.unknownError(detail: "No data received") }
return data
}
// MARK: - Protocol implementation
func importRecipe(url: String) async throws -> RecipeDetail {
let importRequest = RecipeImportRequest(url: url)
guard let body = JSONEncoder.safeEncode(importRequest) else {
throw NetworkError.encodingFailed(detail: "Failed to encode import request")
}
let request = makeRequest(path: "/import", method: .POST, contentType: .JSON, body: body)
return try await sendAndDecode(request)
}
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage? {
let imageSize = (size == .FULL ? "full" : "thumb")
let request = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE)
let data = try await sendRaw(request)
return UIImage(data: data)
}
func getRecipes() async throws -> [Recipe] {
let request = makeRequest(path: "/recipes", method: .GET)
return try await sendAndDecode(request)
}
func createRecipe(_ recipe: RecipeDetail) async throws -> Int {
guard let body = JSONEncoder.safeEncode(recipe) else {
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
}
let request = makeRequest(path: "/recipes", method: .POST, contentType: .JSON, body: body)
let data = try await sendRaw(request)
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
if let id = json as? Int {
return id
}
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
}
func getRecipe(id: Int) async throws -> RecipeDetail {
let request = makeRequest(path: "/recipes/\(id)", method: .GET)
return try await sendAndDecode(request)
}
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int {
guard let body = JSONEncoder.safeEncode(recipe) else {
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
}
let request = makeRequest(path: "/recipes/\(recipe.id)", method: .PUT, contentType: .JSON, body: body)
let data = try await sendRaw(request)
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
if let id = json as? Int {
return id
}
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
}
func deleteRecipe(id: Int) async throws {
let request = makeRequest(path: "/recipes/\(id)", method: .DELETE)
let _ = try await sendRaw(request)
}
func getCategories() async throws -> [Category] {
let request = makeRequest(path: "/categories", method: .GET)
return try await sendAndDecode(request)
}
func getCategory(named categoryName: String) async throws -> [Recipe] {
let request = makeRequest(path: "/category/\(categoryName)", method: .GET)
return try await sendAndDecode(request)
}
func renameCategory(named categoryName: String, to newName: String) async throws {
guard let body = JSONEncoder.safeEncode(["name": newName]) else {
throw NetworkError.encodingFailed(detail: "Failed to encode category name")
}
let request = makeRequest(path: "/category/\(categoryName)", method: .PUT, contentType: .JSON, body: body)
let _ = try await sendRaw(request)
}
func getTags() async throws -> [RecipeKeyword] {
let request = makeRequest(path: "/keywords", method: .GET)
return try await sendAndDecode(request)
}
func getRecipesTagged(keyword: String) async throws -> [Recipe] {
let request = makeRequest(path: "/tags/\(keyword)", method: .GET)
return try await sendAndDecode(request)
}
func searchRecipes(query: String) async throws -> [Recipe] {
let request = makeRequest(path: "/search/\(query)", method: .GET)
return try await sendAndDecode(request)
}
}

View File

@@ -1,51 +0,0 @@
//
// CustomError.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
public enum NetworkError: Error, LocalizedError {
case missingUrl
case encodingFailed(detail: String? = nil)
case decodingFailed(detail: String? = nil)
case httpError(statusCode: Int, body: String? = nil)
case connectionError(underlying: Error? = nil)
case invalidRequest
case unknownError(detail: String? = nil)
public var errorDescription: String? {
switch self {
case .missingUrl:
return "Missing URL."
case .encodingFailed(let detail):
return "Parameter encoding failed." + (detail.map { " \($0)" } ?? "")
case .decodingFailed(let detail):
return "Data decoding failed." + (detail.map { " \($0)" } ?? "")
case .httpError(let statusCode, let body):
return "HTTP error \(statusCode)." + (body.map { " \($0)" } ?? "")
case .connectionError(let underlying):
return "Connection error." + (underlying.map { " \($0.localizedDescription)" } ?? "")
case .invalidRequest:
return "Invalid request."
case .unknownError(let detail):
return "Unknown error." + (detail.map { " \($0)" } ?? "")
}
}
var isClientError: Bool {
if case .httpError(let statusCode, _) = self {
return (400...499).contains(statusCode)
}
return false
}
var isServerError: Bool {
if case .httpError(let statusCode, _) = self {
return (500...599).contains(statusCode)
}
return false
}
}

View File

@@ -14,7 +14,7 @@ struct ApiRequest {
let authString: String? let authString: String?
let headerFields: [HeaderField] let headerFields: [HeaderField]
let body: Data? let body: Data?
init( init(
path: String, path: String,
method: RequestMethod, method: RequestMethod,
@@ -28,21 +28,21 @@ struct ApiRequest {
self.authString = authString self.authString = authString
self.body = body self.body = body
} }
func send(pathCompletion: Bool = true) async -> (Data?, NetworkError?) { func send(pathCompletion: Bool = true) async -> (Data?, NetworkError?) {
Logger.network.debug("\(method.rawValue) \(path) sending ...") Logger.network.debug("\(method.rawValue) \(path) sending ...")
// Prepare URL // Prepare URL
let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path
guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) } print("Full path: \(urlString)")
// Ensure path percent encoding is applied correctly //Logger.network.debug("Full path: \(urlString)")
components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) }
guard let url = components.url else { return (nil, .missingUrl) } guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) }
// Create URL request // Create URL request
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = method.rawValue request.httpMethod = method.rawValue
// Set authentication string, if needed // Set authentication string, if needed
if let authString = authString { if let authString = authString {
request.setValue( request.setValue(
@@ -50,7 +50,7 @@ struct ApiRequest {
forHTTPHeaderField: "Authorization" forHTTPHeaderField: "Authorization"
) )
} }
// Set other header fields // Set other header fields
for headerField in headerFields { for headerField in headerFields {
request.setValue( request.setValue(
@@ -58,38 +58,45 @@ struct ApiRequest {
forHTTPHeaderField: headerField.getField() forHTTPHeaderField: headerField.getField()
) )
} }
// Set http body // Set http body
if let body = body { if let body = body {
request.httpBody = body request.httpBody = body
} }
// Wait for and return data and (decoded) response // Wait for and return data and (decoded) response
var data: Data? = nil
var response: URLResponse? = nil
do { do {
let (data, response) = try await URLSession.shared.data(for: request) (data, response) = try await URLSession.shared.data(for: request)
if let error = decodeURLResponse(response: response as? HTTPURLResponse, data: data) { Logger.network.debug("\(method.rawValue) \(path) received response ...")
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
return (nil, error) return (nil, error)
} }
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") if let data = data {
return (data, nil) print(data, String(data: data, encoding: .utf8) as Any)
return (data, nil)
}
return (nil, .unknownError)
} catch { } catch {
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") let error = decodeURLResponse(response: response as? HTTPURLResponse)
return (nil, .connectionError(underlying: error)) Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)")
return (nil, error)
} }
} }
private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? { private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else { guard let response = response else {
return .unknownError(detail: "No HTTP response") return NetworkError.unknownError
} }
let statusCode = response.statusCode print("Status code: ", response.statusCode)
switch statusCode { switch response.statusCode {
case 200...299: case 200...299: return (nil)
return nil case 300...399: return (NetworkError.redirectionError)
default: case 400...499: return (NetworkError.clientError(statusCode: response.statusCode))
let body = data.flatMap { String(data: $0, encoding: .utf8) } case 500...599: return (NetworkError.serverError(statusCode: response.statusCode))
return .httpError(statusCode: statusCode, body: body) default: return (NetworkError.unknownError)
} }
} }
} }

View File

@@ -0,0 +1,180 @@
//
// CookbookApi.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.11.23.
//
import Foundation
import OSLog
import UIKit
/// The Cookbook API class used for requests to the Nextcloud Cookbook service.
let cookbookApi: CookbookApi.Type = {
switch UserSettings.shared.cookbookApiVersion {
case .v1:
return CookbookApiV1.self
}
}()
/// The Cookbook API version.
enum CookbookApiVersion: String {
case v1 = "v1"
}
/// A protocol defining common API endpoints that are likely to remain the same over future Cookbook API versions.
protocol CookbookApi {
static var basePath: String { get }
/// Not implemented yet.
static func importRecipe(
auth: String,
data: Data
) async -> (Recipe?, NetworkError?)
/// Get either the full image or a thumbnail sized version.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The according recipe id.
/// - size: The size of the image.
/// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil.
static func getImage(
auth: String,
id: String,
size: RecipeImage.RecipeImageSize
) async -> (UIImage?, NetworkError?)
/// Get all recipes.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A list of all recipes.
static func getRecipes(
auth: String
) async -> ([RecipeStub]?, NetworkError?)
/// Create a new recipe.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func createRecipe(
auth: String,
recipe: Recipe
) async -> (NetworkError?)
/// Get the recipe with the specified id.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The recipe id.
/// - Returns: The recipe if it exists. A NetworkError if the request fails.
static func getRecipe(
auth: String,
id: String
) async -> (Recipe?, NetworkError?)
/// Update an existing recipe with new entries.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The recipe id.
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func updateRecipe(
auth: String,
recipe: Recipe
) async -> (NetworkError?)
/// Delete the recipe with the specified id.
/// - Parameters:
/// - auth: Server authentication string.
/// - id: The recipe id.
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func deleteRecipe(
auth: String,
id: String
) async -> (NetworkError?)
/// Get all categories.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A list of categories. A NetworkError if the request fails.
static func getCategories(
auth: String
) async -> ([CookbookApiCategory]?, NetworkError?)
/// Get all recipes of a specified category.
/// - Parameters:
/// - auth: Server authentication string.
/// - categoryName: The category name.
/// - Returns: A list of recipes. A NetworkError if the request fails.
static func getCategory(
auth: String,
named categoryName: String
) async -> ([RecipeStub]?, NetworkError?)
/// Rename an existing category.
/// - Parameters:
/// - auth: Server authentication string.
/// - categoryName: The name of the category to be renamed.
/// - newName: The new category name.
/// - Returns: A NetworkError if the request fails.
static func renameCategory(
auth: String,
named categoryName: String,
newName: String
) async -> (NetworkError?)
/// Get all keywords/tags.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A list of tag strings. A NetworkError if the request fails.
static func getTags(
auth: String
) async -> ([RecipeKeyword]?, NetworkError?)
/// Get all recipes tagged with the specified keyword.
/// - Parameters:
/// - auth: Server authentication string.
/// - keyword: The keyword.
/// - Returns: A list of recipes tagged with the specified keyword. A NetworkError if the request fails.
static func getRecipesTagged(
auth: String,
keyword: String
) async -> ([RecipeStub]?, NetworkError?)
/// Get the servers api version.
/// - Parameters:
/// - auth: Server authentication string.
/// - Returns: A NetworkError if the request fails.
static func getApiVersion(
auth: String
) async -> (NetworkError?)
/// Trigger a reindexing action on the server.
/// - Parameters:
/// - auth: Server authentication string
/// - Returns: A NetworkError if the request fails.
static func postReindex(
auth: String
) async -> (NetworkError?)
/// Get the current configuration of the Cookbook server application.
/// - Parameters:
/// - auth: Server authentication string
/// - Returns: A NetworkError if the request fails.
static func getConfig(
auth: String
) async -> (NetworkError?)
/// Set the current configuration of the Cookbook server application.
/// - Parameters:
/// - auth: Server authentication string
/// - Returns: A NetworkError if the request fails.
static func postConfig(
auth: String
) async -> (NetworkError?)
}

View File

@@ -0,0 +1,229 @@
//
// CookbookApiV1.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.11.23.
//
import Foundation
import UIKit
class CookbookApiV1: CookbookApi {
static let basePath: String = "/index.php/apps/cookbook/api/v1"
static func importRecipe(auth: String, data: Data) async -> (Recipe?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/import",
method: .POST,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
return (recipe?.toRecipe(), error)
}
static func getImage(auth: String, id: String, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
guard let id = Int(id) else {return (nil, .unknownError)}
let imageSize = (size == .FULL ? "full" : "thumb")
let request = ApiRequest(
path: basePath + "/recipes/\(id)/image?size=\(imageSize)",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (UIImage(data: data), error)
}
static func getRecipes(auth: String) async -> ([RecipeStub]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/recipes",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
print("\n\nRECIPE: ", String(data: data, encoding: .utf8))
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
}
static func createRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let recipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
let request = ApiRequest(
path: basePath + "/recipes",
method: .POST,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)],
body: recipeData
)
let (data, error) = await request.send()
guard let data = data else { return (error) }
do {
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
if let id = json as? Int {
return nil
} else if let dict = json as? [String: Any] {
return .unknownError
}
} catch {
return .decodingFailed
}
return nil
}
static func getRecipe(auth: String, id: String) async -> (Recipe?, NetworkError?) {
guard let id = Int(id) else {return (nil, .unknownError)}
let request = ApiRequest(
path: basePath + "/recipes/\(id)",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
return (recipe?.toRecipe(), nil)
}
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
guard let recipeData = JSONEncoder.safeEncode(cookbookRecipe) else {
return .dataError
}
let request = ApiRequest(
path: basePath + "/recipes/\(recipe.id)",
method: .PUT,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)],
body: recipeData
)
let (data, error) = await request.send()
guard let data = data else { return (error) }
do {
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
if let id = json as? Int {
return nil
} else if let dict = json as? [String: Any] {
return .unknownError
}
} catch {
return .decodingFailed
}
return nil
}
static func deleteRecipe(auth: String, id: String) async -> (NetworkError?) {
guard let id = Int(id) else {return .unknownError}
let request = ApiRequest(
path: basePath + "/recipes/\(id)",
method: .DELETE,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (error) }
return nil
}
static func getCategories(auth: String) async -> ([CookbookApiCategory]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/categories",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
}
static func getCategory(auth: String, named categoryName: String) async -> ([RecipeStub]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/category/\(categoryName)",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
}
static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
let request = ApiRequest(
path: basePath + "/category/\(categoryName)",
method: .PUT,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (error) }
return nil
}
static func getTags(auth: String) async -> ([RecipeKeyword]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/keywords",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
return (JSONDecoder.safeDecode(data), nil)
}
static func getRecipesTagged(auth: String, keyword: String) async -> ([RecipeStub]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/tags/\(keyword)",
method: .GET,
authString: auth,
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
)
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
}
static func getApiVersion(auth: String) async -> (NetworkError?) {
return .none
}
static func postReindex(auth: String) async -> (NetworkError?) {
return .none
}
static func getConfig(auth: String) async -> (NetworkError?) {
return .none
}
static func postConfig(auth: String) async -> (NetworkError?) {
return .none
}
}

View File

@@ -1,38 +1,15 @@
// //
// DataModels.swift // CookbookLoginModels.swift
// Nextcloud Cookbook iOS Client // Nextcloud Cookbook iOS Client
// //
// Created by Vincent Meilinger on 15.09.23. // Created by Vincent Meilinger on 11.05.24.
// //
import Foundation import Foundation
import SwiftUI import SwiftUI
struct Category: Codable { // MARK: - Login Models
let name: String
let recipe_count: Int
private enum CodingKeys: String, CodingKey {
case name, recipe_count
}
}
extension Category: Identifiable, Hashable {
var id: String { name }
static func == (lhs: Category, rhs: Category) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
}
// MARK: - Login flow
struct LoginV2Request: Codable { struct LoginV2Request: Codable {
let poll: LoginV2Poll let poll: LoginV2Poll
@@ -62,6 +39,3 @@ struct MetaData: Codable {
let status: String let status: String
let statuscode: Int let statuscode: Int
} }

View File

@@ -8,8 +8,18 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
struct CookbookApiCategory: Codable, Identifiable, Hashable {
var id: String { name }
var name: String
var recipe_count: Int
private enum CodingKeys: String, CodingKey {
case name, recipe_count
}
}
struct Recipe: Codable { struct CookbookApiRecipeV1: CookbookApiRecipe, Codable, Identifiable, Hashable {
var id: String { name + String(recipe_id) }
let name: String let name: String
let keywords: String? let keywords: String?
let dateCreated: String? let dateCreated: String?
@@ -24,15 +34,22 @@ struct Recipe: Codable {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
} }
func toRecipeStub() -> RecipeStub {
return RecipeStub(
id: String(recipe_id),
name: name,
keywords: keywords,
dateCreated: dateCreated,
dateModified: dateModified,
thumbnailPath: nil
)
}
} }
extension Recipe: Identifiable, Hashable {
var id: Int { recipe_id }
}
struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
struct RecipeDetail: Codable {
var name: String var name: String
var keywords: String var keywords: String
var dateCreated: String? var dateCreated: String?
@@ -51,7 +68,7 @@ struct RecipeDetail: Codable {
var recipeInstructions: [String] var recipeInstructions: [String]
var nutrition: [String:String] var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) { init(name: String, keywords: String, dateCreated: String?, dateModified: String?, imageUrl: String?, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String?, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name self.name = name
self.keywords = keywords self.keywords = keywords
self.dateCreated = dateCreated self.dateCreated = dateCreated
@@ -93,74 +110,77 @@ struct RecipeDetail: Codable {
// Custom decoder to handle value type ambiguity // Custom decoder to handle value type ambiguity
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name) name = try container.decode(String.self, forKey: .name)
keywords = (try? container.decode(String.self, forKey: .keywords)) ?? "" keywords = try container.decode(String.self, forKey: .keywords)
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated) dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified) dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
// Server import returns "image"; show/index responses and local storage use "imageUrl" imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
imageUrl = (try? container.decodeIfPresent(String.self, forKey: .image))
?? (try? container.decodeIfPresent(String.self, forKey: .imageUrl))
id = try container.decode(String.self, forKey: .id) id = try container.decode(String.self, forKey: .id)
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime) prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime) cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime) totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
description = (try? container.decode(String.self, forKey: .description)) ?? "" description = try container.decode(String.self, forKey: .description)
url = try? container.decode(String.self, forKey: .url) url = try container.decode(String.self, forKey: .url)
recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? "" recipeYield = try container.decode(Int.self, forKey: .recipeYield)
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
tool = try container.decode([String].self, forKey: .tool)
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
// recipeYield: try Int first, then parse leading digits from String nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
if let yieldInt = try? container.decode(Int.self, forKey: .recipeYield) {
recipeYield = yieldInt
} else if let yieldString = try? container.decode(String.self, forKey: .recipeYield) {
let digits = yieldString.prefix(while: { $0.isNumber })
recipeYield = Int(digits) ?? 0
} else {
recipeYield = 0
}
tool = (try? container.decode([String].self, forKey: .tool)) ?? []
recipeIngredient = (try? container.decode([String].self, forKey: .recipeIngredient)) ?? []
recipeInstructions = (try? container.decode([String].self, forKey: .recipeInstructions)) ?? []
if let nutritionDict = try? container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition) {
nutrition = nutritionDict.mapValues { String(describing: $0.value) }
} else {
nutrition = [:]
}
} }
func encode(to encoder: Encoder) throws { func toRecipe() -> Recipe {
var container = encoder.container(keyedBy: CodingKeys.self) return Recipe(
try container.encode(name, forKey: .name) id: self.id,
try container.encode(keywords, forKey: .keywords) name: self.name,
try container.encodeIfPresent(dateCreated, forKey: .dateCreated) keywords: keywords.components(separatedBy: ","),
try container.encodeIfPresent(dateModified, forKey: .dateModified) dateCreated: self.dateCreated,
// Encode under "image" the key the server expects for create/update dateModified: self.dateModified,
try container.encodeIfPresent(imageUrl, forKey: .image) prepTime: self.prepTime ?? "",
try container.encode(id, forKey: .id) cookTime: self.cookTime ?? "",
try container.encodeIfPresent(prepTime, forKey: .prepTime) totalTime: self.totalTime ?? "",
try container.encodeIfPresent(cookTime, forKey: .cookTime) recipeDescription: self.description,
try container.encodeIfPresent(totalTime, forKey: .totalTime) url: self.url,
try container.encode(description, forKey: .description) yield: self.recipeYield,
try container.encodeIfPresent(url, forKey: .url) category: self.recipeCategory,
try container.encode(recipeYield, forKey: .recipeYield) tools: self.tool,
try container.encode(recipeCategory, forKey: .recipeCategory) ingredients: self.recipeIngredient,
try container.encode(tool, forKey: .tool) instructions: self.recipeInstructions,
try container.encode(recipeIngredient, forKey: .recipeIngredient) nutrition: self.nutrition,
try container.encode(recipeInstructions, forKey: .recipeInstructions) ingredientMultiplier: 1.0
try container.encode(nutrition, forKey: .nutrition) )
}
static func fromRecipe(_ recipe: Recipe) -> any CookbookApiRecipeDetail {
return CookbookApiRecipeDetailV1(
name: recipe.name,
keywords: recipe.keywords.joined(separator: ","),
dateCreated: recipe.dateCreated,
dateModified: recipe.dateModified,
imageUrl: "",
id: recipe.id,
description: recipe.recipeDescription,
url: recipe.url,
recipeYield: recipe.yield,
recipeCategory: recipe.category,
tool: recipe.tools,
recipeIngredient: recipe.ingredients,
recipeInstructions: recipe.instructions,
nutrition: recipe.nutrition
)
} }
} }
extension RecipeDetail { extension CookbookApiRecipeDetailV1 {
static var error: RecipeDetail { static var error: CookbookApiRecipeDetailV1 {
return RecipeDetail( return CookbookApiRecipeDetailV1(
name: "Error: Unable to load recipe.", name: "Error: Unable to load recipe.",
keywords: "", keywords: "",
dateCreated: "", dateCreated: "",
@@ -193,7 +213,7 @@ extension RecipeDetail {
} }
} }
/*
struct RecipeImage { struct RecipeImage {
enum RecipeImageSize: String { enum RecipeImageSize: String {
case THUMB="thumb", FULL="full" case THUMB="thumb", FULL="full"
@@ -202,7 +222,7 @@ struct RecipeImage {
var thumb: UIImage? var thumb: UIImage?
var full: UIImage? var full: UIImage?
} }
*/
struct RecipeKeyword: Codable { struct RecipeKeyword: Codable {
let name: String let name: String
@@ -282,3 +302,4 @@ enum Nutrition: CaseIterable {
} }
} }
} }

View File

@@ -0,0 +1,18 @@
//
// CookbookProtocols.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.05.24.
//
import Foundation
protocol CookbookApiRecipe {
func toRecipeStub() -> RecipeStub
}
protocol CookbookApiRecipeDetail: Codable {
func toRecipe() -> Recipe
static func fromRecipe(_ recipe: Recipe) -> CookbookApiRecipeDetail
}

View File

@@ -0,0 +1,58 @@
//
// CustomError.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
import SwiftUI
public enum NetworkError: UserAlert {
case missingUrl
case parametersNil
case encodingFailed
case decodingFailed
case redirectionError
case clientError(statusCode: Int)
case serverError(statusCode: Int)
case invalidRequest
case unknownError
case dataError
var localizedTitle: LocalizedStringKey {
switch self {
case .missingUrl:
"Missing URL."
case .parametersNil:
"Parameters are nil."
case .encodingFailed:
"Parameter encoding failed."
case .decodingFailed:
"Data decoding failed."
case .redirectionError:
"Redirection error"
case .clientError(let code):
"Client error: \(code)"
case .serverError(let code):
"Server error: \(code)"
case .invalidRequest:
"Invalid request"
case .unknownError:
"Unknown error"
case .dataError:
"Invalid data error."
}
}
var localizedDescription: LocalizedStringKey {
return self.localizedTitle
}
var alertButtons: [AlertButton] {
return [.OK]
}
}

View File

@@ -44,3 +44,7 @@ struct HeaderField {
return HeaderField(_field: "Content-Type", _value: value.rawValue) return HeaderField(_field: "Content-Type", _value: value.rawValue)
} }
} }
struct RecipeImportRequest: Codable {
let url: String
}

View File

@@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication. /// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
@@ -21,10 +20,9 @@ class NextcloudApi {
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful. /// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any. /// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Request() async -> (LoginV2Request?, NetworkError?) { static func loginV2Request(_ baseAddress: String) async -> (LoginV2Request?, NetworkError?) {
let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress
let request = ApiRequest( let request = ApiRequest(
path: path + "/index.php/login/v2", path: baseAddress + "/index.php/login/v2",
method: .POST method: .POST
) )
@@ -34,10 +32,10 @@ class NextcloudApi {
return (nil, error) return (nil, error)
} }
guard let data = data else { guard let data = data else {
return (nil, NetworkError.encodingFailed()) return (nil, NetworkError.dataError)
} }
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else { guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
return (nil, NetworkError.decodingFailed()) return (nil, NetworkError.decodingFailed)
} }
return (loginRequest, nil) return (loginRequest, nil)
} }
@@ -53,16 +51,16 @@ class NextcloudApi {
/// - `LoginV2Response?`: An object representing the response of the login process, if successful. /// - `LoginV2Response?`: An object representing the response of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any. /// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) { static func loginV2Poll(pollURL: String, pollToken: String) async -> (LoginV2Response?, NetworkError?) {
let request = ApiRequest( let request = ApiRequest(
path: req.poll.endpoint, path: pollURL,
method: .POST, method: .POST,
headerFields: [ headerFields: [
HeaderField.ocsRequest(value: true), HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON), HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM) HeaderField.contentType(value: .FORM)
], ],
body: "token=\(req.poll.token)".data(using: .utf8) body: "token=\(pollToken)".data(using: .utf8)
) )
let (data, error) = await request.send(pathCompletion: false) let (data, error) = await request.send(pathCompletion: false)
@@ -70,10 +68,10 @@ class NextcloudApi {
return (nil, error) return (nil, error)
} }
guard let data = data else { guard let data = data else {
return (nil, NetworkError.encodingFailed()) return (nil, NetworkError.dataError)
} }
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else { guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
return (nil, NetworkError.decodingFailed()) return (nil, NetworkError.decodingFailed)
} }
return (loginResponse, nil) return (loginResponse, nil)
} }
@@ -108,11 +106,11 @@ class NextcloudApi {
userId: data?["userId"] as? String ?? "", userId: data?["userId"] as? String ?? "",
userDisplayName: data?["displayName"] as? String ?? "" userDisplayName: data?["displayName"] as? String ?? ""
) )
Logger.network.debug("Loaded hover card for user \(userData.userId)") print(userData)
return (userData, nil) return (userData, nil)
} catch { } catch {
Logger.network.error("Failed to decode hover card: \(error.localizedDescription)") print(error.localizedDescription)
return (nil, NetworkError.decodingFailed()) return (nil, NetworkError.decodingFailed)
} }
} }
} }

View File

@@ -6,8 +6,7 @@
// //
import SwiftUI import SwiftUI
import SwiftData
import SwiftUI
@main @main
struct Nextcloud_Cookbook_iOS_ClientApp: App { struct Nextcloud_Cookbook_iOS_ClientApp: App {
@@ -18,9 +17,11 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
WindowGroup { WindowGroup {
ZStack { ZStack {
if onboarding { if onboarding {
OnboardingView() //OnboardingView()
EmptyView()
} else { } else {
MainView() MainView()
.modelContainer(for: [Recipe.self, GroceryItem.self, RecipeGroceries.self])
} }
} }
.transition(.slide) .transition(.slide)
@@ -29,6 +30,10 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
.init(identifier: language == .init(identifier: language ==
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language) SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
) )
.onAppear {
AuthManager.shared.loadAuthString() // Load the auth string as soon as possible
}
} }
} }
} }

View File

@@ -0,0 +1,222 @@
//
// CookbookState.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.24.
//
import Foundation
import SwiftUI
/*
@Observable
class CookbookState {
/// Caches recipe categories.
var categories: [Category] = []
/// Caches RecipeStubs.
var recipeStubs: [String: [RecipeStub]] = [:]
/// Caches Recipes by recipe id.
var recipes: [String: Recipe] = [:]
/// Caches recipe thumbnails by recipe id.
var thumbnails: [String: UIImage] = [:]
/// Caches recipe images by recipe id.
var images: [String: UIImage] = [:]
/// Caches recipe keywords.
var keywords: [RecipeKeyword] = []
/// Read and write interfaces.
var localReadInterface: ReadInterface
var localWriteInterface: WriteInterface
var remoteReadInterface: ReadInterface?
var remoteWriteInterface: WriteInterface?
/// UI state variables
var selectedCategory: Category? = nil
var selectedRecipeStub: RecipeStub? = nil
var showSettings: Bool = false
var showGroceries: Bool = false
/// Grocery List
var groceryList = GroceryList()
init(
localReadInterface: ReadInterface,
localWriteInterface: WriteInterface,
remoteReadInterface: ReadInterface? = nil,
remoteWriteInterface: WriteInterface? = nil
) {
self.localReadInterface = localReadInterface
self.localWriteInterface = localWriteInterface
self.remoteReadInterface = remoteReadInterface
self.remoteWriteInterface = remoteWriteInterface
}
init() {
let accountLoader = AccountLoader()
rI, wI = accountLoader.load
}
}
extension CookbookState {
func loadCategories(remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let categories = await remoteReadInterface.getCategories() {
self.categories = categories
return
}
if let categories = await localReadInterface.getCategories() {
self.categories = categories
return
}
} else {
if let categories = await localReadInterface.getCategories() {
self.categories = categories
return
}
guard let remoteReadInterface else { return }
if let categories = await remoteReadInterface.getCategories() {
self.categories = categories
return
}
}
}
func loadRecipeStubs(category: String, remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let stubs = await remoteReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
if let stubs = await localReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
} else {
if let stubs = await localReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
guard let remoteReadInterface else { return }
if let stubs = await remoteReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
}
}
func loadKeywords(remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let keywords = await remoteReadInterface.getTags() {
self.keywords = keywords
return
}
if let keywords = await localReadInterface.getTags() {
self.keywords = keywords
return
}
} else {
if let keywords = await localReadInterface.getTags() {
self.keywords = keywords
return
}
guard let remoteReadInterface else { return }
if let keywords = await remoteReadInterface.getTags() {
self.keywords = keywords
return
}
}
}
func loadRecipe(id: String, remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let recipe = await remoteReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
if let recipe = await localReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
} else {
if let recipe = await localReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
guard let remoteReadInterface else { return }
if let recipe = await remoteReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
}
}
}
class AccountLoader {
func initInterfaces() async -> [ReadInterface & WriteInterface] {
let accounts = await self.loadAccounts("accounts.data")
if accounts.isEmpty && UserSettings.shared.serverAddress != "" {
print("Creating new Account from legacy Cookbook client account.")
let auth = Authentication(
baseUrl: UserSettings.shared.serverAddress,
user: UserSettings.shared.username,
token: UserSettings.shared.authString
)
let authKey = "legacyNextcloud"
let legacyAccount = Account(
id: UUID(),
name: "Nextcloud",
type: .nextcloud,
apiVersion: "1.0",
authKey: authKey
)
await saveAccounts([legacyAccount], "accounts.data")
legacyAccount.storeAuth(auth)
let interface = NextcloudDataInterface(auth: auth, version: legacyAccount.apiVersion)
return [interface]
} else {
print("Recovering existing accounts.")
var interfaces: [ReadInterface & WriteInterface] = []
for account in accounts {
if let interface: CookbookInterface = account.getInterface() {
interfaces.append(interface)
}
}
return interfaces
}
}
func loadAccounts(_ path: String) async -> [Account] {
do {
return try await DataStore.shared.load(fromPath: path) ?? []
} catch (let error) {
print(error)
return []
}
}
func saveAccounts(_ accounts: [Account], _ path: String) async {
await DataStore.shared.save(data: accounts, toPath: path)
}
}
*/

View File

@@ -0,0 +1,114 @@
//
// PersistenceInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 06.05.24.
//
import Foundation
import SwiftUI
import KeychainSwift
/*
protocol CookbookInterface {
/// A unique id of the interface. Used to associate recipes to their respective accounts.
var id: String { get }
}
protocol ReadInterface {
/// Get either the full image or a thumbnail sized version.
/// - Parameters:
/// - id: The according recipe id.
/// - size: The size of the image.
/// - Returns: The image of the recipe with the specified id. A UserAlert if the request fails, otherwise nil.
func getImage(
id: String,
size: RecipeImage.RecipeImageSize
) async -> UIImage?
/// Get all recipe stubs.
/// - Returns: A list of all recipes.
func getRecipeStubs(
) async -> [RecipeStub]?
/// Get the recipe with the specified id.
/// - Parameters:
/// - id: The recipe id.
/// - Returns: The recipe if it exists. A UserAlert if the request fails.
func getRecipe(
id: String
) async -> Recipe?
/// Get all categories.
/// - Returns: A list of categories. A UserAlert if the request fails.
func getCategories(
) async -> [Category]?
/// Get all recipes of a specified category.
/// - Parameters:
/// - categoryName: The category name.
/// - Returns: A list of recipes. A UserAlert if the request fails.
func getRecipeStubsForCategory(
named categoryName: String
) async -> [RecipeStub]?
/// Get all keywords/tags.
/// - Returns: A list of tag strings. A UserAlert if the request fails.
func getTags(
) async -> [RecipeKeyword]?
/// Get all recipes tagged with the specified keyword.
/// - Parameters:
/// - keyword: The keyword.
/// - Returns: A list of recipes tagged with the specified keyword. A UserAlert if the request fails.
func getRecipesTagged(
keyword: String
) async -> [RecipeStub]?
}
protocol WriteInterface {
/// Post either the full image or a thumbnail sized version.
/// - Parameters:
/// - id: The according recipe id.
/// - size: The size of the image.
/// - Returns: A UserAlert if the request fails, otherwise nil.
func postImage(
id: String,
image: UIImage,
size: RecipeImage.RecipeImageSize
) async -> (UserAlert?)
/// Create a new recipe.
/// - Parameters:
/// - Returns: A UserAlert if the request fails. Nil otherwise.
func postRecipe(
recipe: Recipe
) async -> (UserAlert?)
/// Update an existing recipe with new entries.
/// - Parameters:
/// - recipe: The recipe.
/// - Returns: A UserAlert if the request fails. Nil otherwise.
func updateRecipe(
recipe: Recipe
) async -> (UserAlert?)
/// Delete the recipe with the specified id.
/// - Parameters:
/// - id: The recipe id.
/// - Returns: A UserAlert if the request fails. Nil otherwise.
func deleteRecipe(
id: String
) async -> (UserAlert?)
/// Rename an existing category.
/// - Parameters:
/// - categoryName: The name of the category to be renamed.
/// - newName: The new category name.
/// - Returns: A UserAlert if the request fails.
func renameCategory(
named categoryName: String,
newName: String
) async -> (UserAlert?)
}
*/

View File

@@ -0,0 +1,151 @@
//
// LocalDataInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 07.05.24.
//
import Foundation
import SwiftUI
/*
class LocalDataInterface: CookbookInterface {
var id: String
init(id: String) {
self.id = id
}
enum LocalDataPath {
case recipeStubs(category: String),
recipe(id: String),
image(id: String, size: RecipeImage.RecipeImageSize),
categories,
keywords
var path: String {
switch self {
case .recipe(let id):
"recipe_\(id).data"
case .recipeStubs(let category):
"recipes_\(category).data"
case .image(let id, let size):
if size == .FULL {
"image_\(id).data"
} else {
"thumb_\(id).data"
}
case .categories:
"categories.data"
case .keywords:
"keywords.data"
}
}
}
}
// MARK: - Local Read Interface
extension LocalDataInterface: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
guard let data: String = await load(path: .image(id: id, size: size)) else {
return nil
}
guard let dataDecoded = Data(base64Encoded: data) else { return nil }
return UIImage(data: dataDecoded)
}
func getRecipeStubs() async -> [RecipeStub]? {
return nil
}
func getRecipe(id: String) async -> Recipe? {
return await load(path: LocalDataPath.recipe(id: id))
}
func getCategories() async -> [Category]? {
return await load(path: LocalDataPath.categories)
}
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
return await load(path: .recipeStubs(category: categoryName))
}
func getTags() async -> [RecipeKeyword]? {
return await load(path: .keywords)
}
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
return nil
}
}
// MARK: - Local Write Interface
extension LocalDataInterface: WriteInterface {
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
if let data = image.pngData() {
await save(
data,
path: LocalDataPath.image(id: id, size: size)
)
}
return nil
}
func postRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
await save(recipe, path: LocalDataPath.recipe(id: recipe.id))
return nil
}
func updateRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
return await postRecipe(recipe: recipe)
}
func deleteRecipe(id: String) async -> ((any UserAlert)?) {
await delete(path: .recipe(id: id))
return nil
}
func renameCategory(named categoryName: String, newName: String) async -> ((any UserAlert)?) {
guard let stubs: [RecipeStub] = await load(path: .recipeStubs(category: categoryName)) else {
return PersistenceAlert.LOAD_FAILED
}
await save(stubs, path: .recipeStubs(category: newName))
await delete(path: .recipeStubs(category: categoryName))
return nil
}
}
// MARK: - Local Data Interface Utils
extension LocalDataInterface {
func load<T: Codable>(path ldPath: LocalDataPath) async -> T? {
do {
return try await DataStore.shared.load(fromPath: ldPath.path)
} catch (let error) {
print(error)
return nil
}
}
func save<T: Codable>(_ object: T, path ldPath: LocalDataPath) async {
await DataStore.shared.save(data: object, toPath: ldPath.path)
}
func delete(path ldPath: LocalDataPath) async {
DataStore.shared.delete(path: ldPath.path)
}
}
*/

View File

@@ -0,0 +1,92 @@
//
// NextcloudDataInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 07.05.24.
//
import Foundation
import SwiftUI
/*
class NextcloudDataInterface: CookbookInterface {
var id: String
var auth: Authentication
var api: CookbookApi.Type
init(auth: Authentication, version: String) {
self.id = UUID().uuidString
self.auth = auth
switch version {
case "1.0":
self.api = CookbookApiV1.self
default:
self.api = CookbookApiV1.self
}
}
}
// MARK: - Nextcloud Read Interface
extension NextcloudDataInterface: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
return await api.getImage(auth: auth.token, id: id, size: size).0
}
func getRecipeStubs() async -> [RecipeStub]? {
return await api.getRecipes(auth: auth.token).0
}
func getRecipe(id: String) async -> Recipe?{
return await api.getRecipe(auth: auth.token, id: id).0
}
func getCategories() async -> [Category]? {
return await api.getCategories(auth: auth.token).0
}
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
return await api.getCategory(
auth: UserSettings.shared.authString,
named: categoryName
).0
}
func getTags() async -> [RecipeKeyword]? {
return await api.getTags(auth: auth.token).0
}
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
return await api.getRecipesTagged(auth: auth.token, keyword: keyword).0
}
}
// MARK: - Nextcloud Write Interface
extension NextcloudDataInterface: WriteInterface {
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
return nil
}
func postRecipe(recipe: Recipe) async -> (UserAlert?) {
return await api.createRecipe(auth: auth.token, recipe: recipe)
}
func updateRecipe(recipe: Recipe) async -> (UserAlert?) {
return await api.updateRecipe(auth: auth.token, recipe: recipe)
}
func deleteRecipe(id: String) async -> (UserAlert?) {
return await api.deleteRecipe(auth: auth.token, id: id)
}
func renameCategory(named categoryName: String, newName: String) async -> (UserAlert?) {
return await api.renameCategory(auth: auth.token, named: categoryName, newName: newName)
}
}
*/

View File

@@ -11,7 +11,7 @@ import SwiftUI
class RecipeExporter { class RecipeExporter {
func createPDF(recipe: RecipeDetail, image: UIImage?) -> URL? { func createPDF(recipe: CookbookApiRecipeDetailV1, image: UIImage?) -> URL? {
let document = PDFDocument(format: .a4) let document = PDFDocument(format: .a4)
let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black) let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black)
@@ -82,7 +82,7 @@ class RecipeExporter {
} }
} }
func createText(recipe: RecipeDetail) -> String { func createText(recipe: CookbookApiRecipeDetailV1) -> String {
var recipeString = "" var recipeString = ""
recipeString.append("" + recipe.name + "\n") recipeString.append("" + recipe.name + "\n")
recipeString.append(recipe.description + "\n\n") recipeString.append(recipe.description + "\n\n")
@@ -99,7 +99,7 @@ class RecipeExporter {
return recipeString return recipeString
} }
func createJson(recipe: RecipeDetail) -> Data? { func createJson(recipe: CookbookApiRecipeDetailV1) -> Data? {
return JSONEncoder.safeEncode(recipe) return JSONEncoder.safeEncode(recipe)
} }
} }

View File

@@ -0,0 +1,128 @@
//
// RecipeScraper.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 09.11.23.
//
import Foundation
import SwiftSoup
import SwiftUI
class RecipeScraper {
func scrape(url: String) async throws -> (CookbookApiRecipeDetailV1?, RecipeImportAlert?) {
var contents: String? = nil
if let url = URL(string: url) {
do {
contents = try String(contentsOf: url)
} catch {
print("ERROR: Could not load url content.")
return (nil, .CHECK_CONNECTION)
}
} else {
print("ERROR: Bad url.")
return (nil, .BAD_URL)
}
guard let html = contents else {
print("ERROR: no contents")
return (nil, .WEBSITE_NOT_SUPPORTED)
}
let doc = try SwiftSoup.parse(html)
let elements: Elements = try doc.select("script")
for elem in elements.array() {
for attr in elem.getAttributes()!.asList() {
if attr.getValue() == "application/ld+json" {
guard let dict = toDict(elem) else { continue }
if let recipe = getRecipe(fromDict: dict) {
return (recipe, nil)
}
}
}
}
return (nil, .WEBSITE_NOT_SUPPORTED)
}
private func toDict(_ elem: Element) -> [String: Any]? {
var recipeDict: [String: Any]? = nil
do {
let jsonString = try elem.html()
let json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: .fragmentsAllowed)
if let recipe = json as? [String : Any] {
recipeDict = recipe
} else if let recipe = (json as! [Any])[0] as? [String : Any] {
recipeDict = recipe
}
} catch {
print("Unable to decode json")
return nil
}
guard let recipeDict = recipeDict else {
print("Json is not a dict")
return nil
}
if recipeDict["@type"] as? String ?? "" == "Recipe" {
return recipeDict
} else if (recipeDict["@type"] as? [String] ?? []).contains("Recipe") {
return recipeDict
} else {
print("Json dict is not a recipe ...")
return nil
}
}
private func getRecipe(fromDict recipe: Dictionary<String, Any>) -> CookbookApiRecipeDetailV1? {
var recipeDetail = CookbookApiRecipeDetailV1()
recipeDetail.name = recipe["name"] as? String ?? "New Recipe"
recipeDetail.recipeCategory = recipe["recipeCategory"] as? String ?? ""
recipeDetail.keywords = joinedStringForKey("keywords", dict: recipe)
recipeDetail.description = recipe["description"] as? String ?? ""
recipeDetail.dateCreated = recipe["dateCreated"] as? String ?? ""
recipeDetail.dateModified = recipe["dateModified"] as? String ?? ""
recipeDetail.imageUrl = recipe["imageUrl"] as? String ?? ""
recipeDetail.url = recipe["url"] as? String ?? ""
recipeDetail.cookTime = recipe["cookTime"] as? String ?? ""
recipeDetail.prepTime = recipe["prepTime"] as? String ?? ""
recipeDetail.totalTime = recipe["totalTime"] as? String ?? ""
recipeDetail.recipeInstructions = stringArrayForKey("recipeInstructions", dict: recipe)
recipeDetail.recipeYield = recipe["recipeYield"] as? Int ?? 0
recipeDetail.recipeIngredient = recipe["recipeIngredient"] as? [String] ?? []
recipeDetail.tool = stringArrayForKey("tool", dict: recipe)
recipeDetail.nutrition = recipe["nutrition"] as? [String:String] ?? [:]
print(recipeDetail)
return recipeDetail
}
private func stringArrayForKey(_ key: String, dict: Dictionary<String, Any>) -> [String] {
if let text = dict[key] as? String {
return [text]
} else if let value = dict[key] as? [String] {
return value
} else if let orderedList = dict[key] as? [Any] {
var entries: [String] = []
for dict in orderedList {
guard let dict = dict as? [String: Any] else { continue }
guard let text = dict["text"] as? String else { continue }
entries.append(text)
}
return entries
}
return []
}
private func joinedStringForKey(_ key: String, dict: Dictionary<String, Any>) -> String {
if let value = dict[key] as? [String] {
return value.joined(separator: ",")
} else if let value = dict[key] as? String {
return value
}
return ""
}
}

View File

@@ -0,0 +1,9 @@
import SwiftSoup
import Foundation
//let url = "https://www.chefkoch.de/rezepte/1385981243676608/Knusprige-Entenbrust.html"
let url = "https://www.allrecipes.com/recipe/234620/mascarpone-mashed-potatoes/"
let scraper = RecipeScaper()

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
<timeline fileName='timeline.xctimeline'/>
</playground>

View File

@@ -94,6 +94,33 @@ enum RecipeAlert: UserAlert {
} }
enum RecipeImportAlert: UserAlert {
case BAD_URL,
CHECK_CONNECTION,
WEBSITE_NOT_SUPPORTED
var localizedDescription: LocalizedStringKey {
switch self {
case .BAD_URL: return "Please check the entered URL."
case .CHECK_CONNECTION: return "Unable to load website content. Please check your internet connection."
case .WEBSITE_NOT_SUPPORTED: return "This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue."
}
}
var localizedTitle: LocalizedStringKey {
switch self {
case .BAD_URL: return "Bad URL"
case .CHECK_CONNECTION: return "Connection error"
case .WEBSITE_NOT_SUPPORTED: return "Parsing error"
}
}
var alertButtons: [AlertButton] {
return [.OK]
}
}
enum RequestAlert: UserAlert { enum RequestAlert: UserAlert {
case REQUEST_DELAYED, case REQUEST_DELAYED,
REQUEST_DROPPED, REQUEST_DROPPED,
@@ -119,3 +146,33 @@ enum RequestAlert: UserAlert {
return [.OK] return [.OK]
} }
} }
enum PersistenceAlert: UserAlert {
case DECODING_FAILED,
ENCODING_FAILED,
SAVE_FAILED,
LOAD_FAILED
var localizedDescription: LocalizedStringKey {
switch self {
case .DECODING_FAILED: return "Unable to decode recipe data."
case .ENCODING_FAILED: return "Unable to encode recipe data."
case .SAVE_FAILED: return "Unable to save recipe."
case .LOAD_FAILED: return "Unable to load recipe."
}
}
var localizedTitle: LocalizedStringKey {
switch self {
case .DECODING_FAILED: return "Decoding Error"
case .ENCODING_FAILED: return "Encoding Error"
case .SAVE_FAILED: return "Error"
case .LOAD_FAILED: return "Error"
}
}
var alertButtons: [AlertButton] {
return [.OK]
}
}

View File

@@ -37,7 +37,28 @@ class DurationComponents: ObservableObject {
} }
} }
init() {
}
init(_ hours: Int, _ min: Int, _ sec: Int = 0) {
self.hourComponent = hours
self.minuteComponent = min
self.secondComponent = sec
}
required init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let durationString = try container.decode(String.self)
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = durationString.firstMatch(of: hourRegex) {
self.hourComponent = Int(match.1) ?? 0
}
if let match = durationString.firstMatch(of: minuteRegex) {
self.minuteComponent = Int(match.1) ?? 0
}
}
var displayString: String { var displayString: String {
if hourComponent != 0 && minuteComponent != 0 { if hourComponent != 0 && minuteComponent != 0 {
@@ -144,3 +165,11 @@ class DurationComponents: ObservableObject {
return result return result
} }
} }
extension DurationComponents: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
let durationString = toPTString()
try container.encode(durationString)
}
}

View File

@@ -6,45 +6,41 @@
// //
import SwiftUI import SwiftUI
import SwiftData
struct MainView: View { struct MainView: View {
@StateObject var appState = AppState()
@StateObject var groceryList = GroceryList()
// Tab ViewModels // Tab ViewModels
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
@StateObject var searchViewModel = SearchTabView.ViewModel()
@State private var selectedTab: Tab = .recipes
enum Tab { enum Tab {
case recipes, search, groceryList case recipes, settings, groceryList
} }
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView {
SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) { RecipeTabView()
RecipeTabView() .tabItem {
.environmentObject(recipeViewModel) Label("Recipes", systemImage: "book.closed.fill")
.environmentObject(appState) }
.environmentObject(groceryList) .tag(Tab.recipes)
}
GroceryListTabView()
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) { .tabItem {
SearchTabView() if #available(iOS 17.0, *) {
.environmentObject(searchViewModel) Label("Grocery List", systemImage: "storefront")
.environmentObject(appState) } else {
.environmentObject(groceryList) Label("Grocery List", systemImage: "heart.text.square")
} }
}
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) { .tag(Tab.groceryList)
GroceryListTabView()
.environmentObject(groceryList) SettingsTabView()
} .tabItem {
Label("Settings", systemImage: "gear")
}
.tag(Tab.settings)
} }
.tabViewStyle(.sidebarAdaptable)
.modifier(TabBarMinimizeModifier())
.task { .task {
/*
recipeViewModel.presentLoadingIndicator = true recipeViewModel.presentLoadingIndicator = true
await appState.getCategories() await appState.getCategories()
await appState.updateAllRecipeDetails() await appState.updateAllRecipeDetails()
@@ -62,6 +58,94 @@ struct MainView: View {
} }
await groceryList.load() await groceryList.load()
recipeViewModel.presentLoadingIndicator = false recipeViewModel.presentLoadingIndicator = false
*/
} }
} }
} }
/*struct CategoryListView: View {
@Bindable var cookbookState: CookbookState
var body: some View {
List(cookbookState.selectedAccountState.categories) { category in
NavigationLink {
RecipeListView(
cookbookState: cookbookState,
selectedCategory: category.name,
showEditView: .constant(false)
)
} label: {
HStack(alignment: .center) {
if cookbookState.selectedAccountState.selectedCategory != nil &&
category.name == cookbookState.selectedAccountState.selectedCategory?.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
}
}*/
/*struct CategoryListView: View {
@State var state: CookbookState
var body: some View {
List(selection: $state.categoryListSelection) {
ForEach(state.categories) { category in
NavigationLink(value: category) {
HStack(alignment: .center) {
if state.categoryListSelection != nil &&
category.name == state.categoryListSelection {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
}
}
}*/

View File

@@ -6,9 +6,8 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
/*
struct OnboardingView: View { struct OnboardingView: View {
@State var selectedTab: Int = 0 @State var selectedTab: Int = 0
@@ -149,7 +148,7 @@ struct BorderedLoginTextField: View {
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundColor(color) .foregroundColor(color)
.tint(color) .accentColor(color)
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
@@ -171,7 +170,7 @@ struct LoginTextField: View {
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundColor(color) .foregroundColor(color)
.tint(color) .accentColor(color)
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
@@ -204,7 +203,7 @@ struct ServerAddressField: View {
.tint(.white) .tint(.white)
.font(.headline) .font(.headline)
.onChange(of: serverProtocol) { value in .onChange(of: serverProtocol) { value in
Logger.view.debug("\(value.rawValue)") print(value)
userSettings.serverProtocol = value.rawValue userSettings.serverProtocol = value.rawValue
} }
@@ -245,3 +244,4 @@ struct ServerAddressField_Preview: PreviewProvider {
.background(Color.nextcloudBlue) .background(Color.nextcloudBlue)
} }
} }
*/

View File

@@ -6,11 +6,10 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
/*
struct TokenLoginView: View { struct TokenLoginView: View {
@Binding var showAlert: Bool @Binding var showAlert: Bool
@Binding var alertMessage: String @Binding var alertMessage: String
@@ -73,7 +72,7 @@ struct TokenLoginView: View {
case .username: case .username:
focusedField = .token focusedField = .token
default: default:
Logger.view.debug("Attempting to log in ...") print("Attempting to log in ...")
} }
} }
} }
@@ -88,16 +87,22 @@ struct TokenLoginView: View {
showAlert = true showAlert = true
return false return false
} }
UserSettings.shared.setAuthString() UserSettings.shared.setAuthString()
let client = CookbookApiFactory.makeClient() let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString)
do {
let _ = try await client.getCategories() if let error = error {
return true
} catch {
alertMessage = "Login failed. Please check your inputs and internet connection." alertMessage = "Login failed. Please check your inputs and internet connection."
showAlert = true showAlert = true
return false return false
} }
guard let data = data else {
alertMessage = "Login failed. Please check your inputs."
showAlert = true
return false
}
return true
} }
} }
*/

View File

@@ -6,9 +6,15 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import WebKit import WebKit
import AuthenticationServices
protocol LoginStage {
func next() -> Self
func previous() -> Self
}
enum V2LoginStage: LoginStage { enum V2LoginStage: LoginStage {
case login, validate case login, validate
@@ -31,12 +37,27 @@ enum V2LoginStage: LoginStage {
struct V2LoginView: View { struct V2LoginView: View {
@Binding var showAlert: Bool @Environment(\.dismiss) var dismiss
@Binding var alertMessage: String @State var showAlert: Bool = false
@State var alertMessage: String = ""
@State var loginStage: V2LoginStage = .login @State var loginStage: V2LoginStage = .login
@State var loginRequest: LoginV2Request? = nil @State var loginRequest: LoginV2Request? = nil
@State var presentBrowser = false @State var presentBrowser = false
@State var serverAddress: String = ""
@State var serverProtocol: ServerProtocol = .https
@State var loginPressed: Bool = false
@State var isLoading: Bool = false
// Task reference for polling, to cancel if needed
@State private var pollTask: Task<Void, Never>? = nil
enum ServerProtocol: String {
case https="https://", http="http://"
static let all = [https, http]
}
// TextField handling // TextField handling
enum Field { enum Field {
@@ -46,114 +67,205 @@ struct V2LoginView: View {
} }
var body: some View { var body: some View {
ScrollView { VStack {
VStack(alignment: .leading) { HStack {
ServerAddressField() Button("Cancel") {
CollapsibleView { dismiss()
VStack(alignment: .leading) {
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.")
.padding(.bottom)
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.")
.padding(.bottom)
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
}
} title: {
Text("Show help")
.foregroundColor(.white)
.font(.headline)
}.padding()
if loginRequest != nil {
Button("Copy Link") {
UIPasteboard.general.string = loginRequest!.login
}
.font(.headline)
.foregroundStyle(.white)
.padding()
} }
Spacer()
HStack { if isLoading {
Button { ProgressView()
if UserSettings.shared.serverAddress == "" { }
alertMessage = "Please enter a valid server address." }.padding()
showAlert = true
return Form {
} Section {
HStack {
Task { Text("Server address:")
let error = await sendLoginV2Request() TextField("example.com", text: $serverAddress)
if let error = error { .multilineTextAlignment(.trailing)
alertMessage = "A network error occured (\(error.localizedDescription))." .autocorrectionDisabled()
showAlert = true .textInputAutocapitalization(.never)
} }
if let loginRequest = loginRequest {
presentBrowser = true
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Text("Login")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}.padding()
if loginStage == .validate { Picker("Server Protocol:", selection: $serverProtocol) {
Spacer() ForEach(ServerProtocol.all, id: \.self) {
Text($0.rawValue)
Button {
// fetch login v2 response
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
} }
.disabled(loginRequest == nil ? true : false) }
.padding()
HStack {
Button("Login") {
initiateLoginV2()
}
Spacer()
Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines))
.foregroundStyle(Color.secondary)
}
} header: {
Text("Nextcloud Login")
} footer: {
Text(
"""
The 'Login' button will open a web browser. Please follow the login instructions provided there.
After a successful login, return to this application and press 'Validate'.
If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.
"""
)
}.disabled(loginPressed)
if let loginRequest = loginRequest {
Section {
Text(loginRequest.login)
.font(.caption)
.foregroundStyle(.secondary)
Button("Copy Link") {
UIPasteboard.general.string = loginRequest.login
}
} footer: {
Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.")
} }
} }
} }
} }
.sheet(isPresented: $presentBrowser, onDismiss: { .sheet(isPresented: $presentBrowser) {
Task { if let loginReq = loginRequest {
let (response, error) = await fetchLoginV2Response() LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
checkLogin(response: response, error: error) switch result {
case .success(let url):
print("Login completed with URL: \(url)")
dismiss()
case .failure(let error):
print("Login failed: \(error.localizedDescription)")
self.alertMessage = error.localizedDescription
self.isLoading = false
self.loginPressed = false
self.showAlert = true
}
}
} else {
Text("Error: Login URL not available.")
} }
}) { }
if let loginRequest = loginRequest { .alert("Error", isPresented: $showAlert) {
WebViewSheet(url: loginRequest.login) Button("Copy Error") {
print("Error copied: \(alertMessage)")
UIPasteboard.general.string = alertMessage
isLoading = false
loginPressed = false
} }
Button("Dismiss") {
print("Error dismissed.")
isLoading = false
loginPressed = false
}
} message: {
Text(alertMessage)
} }
} }
func sendLoginV2Request() async -> NetworkError? { func initiateLoginV2() {
let (req, error) = await NextcloudApi.loginV2Request() isLoading = true
self.loginRequest = req loginPressed = true
return error
Task {
let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)
let (req, error) = await NextcloudApi.loginV2Request(baseAddress)
if let error = error {
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return
}
guard let req = req else {
self.alertMessage = "Failed to get login URL from server."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return
}
self.loginRequest = req
// Present the browser session
presentBrowser = true
// Start polling in a separate task
startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token)
}
} }
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) { func startPolling(pollURL: String, pollToken: String) {
guard let loginRequest = loginRequest else { return (nil, .invalidRequest) } // Cancel any existing poll task first
return await NextcloudApi.loginV2Response(req: loginRequest) pollTask?.cancel()
var pollingFailed = true
pollTask = Task {
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
for _ in 0..<maxRetries {
if Task.isCancelled {
print("Task cancelled.")
break
}
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
if Task.isCancelled {
print("Task cancelled.")
break
}
if let response = response {
// Success
print("Task succeeded.")
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
pollingFailed = false
await MainActor.run {
self.checkLogin(response: response, error: nil)
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
self.isLoading = false
self.loginPressed = false
}
return
} else if let error = error {
if case .clientError(statusCode: 404) = error {
// Continue polling
print("Polling unsuccessful, continuing.")
} else {
// A more serious error occurred during polling
print("Polling error: \(error.localizedDescription)")
await MainActor.run {
self.alertMessage = "Polling error: \(error.localizedDescription)"
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
return
}
}
isLoading = true
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
isLoading = false
}
// If polling finishes without success
if !Task.isCancelled && pollingFailed {
await MainActor.run {
self.alertMessage = "Login timed out. Please try again."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
}
}
} }
func checkLogin(response: LoginV2Response?, error: NetworkError?) { func checkLogin(response: LoginV2Response?, error: NetworkError?) {
@@ -167,7 +279,7 @@ struct V2LoginView: View {
showAlert = true showAlert = true
return return
} }
Logger.network.debug("Login successful for user \(response.loginName)!") print("Login successful for user \(response.loginName)!")
UserSettings.shared.username = response.loginName UserSettings.shared.username = response.loginName
UserSettings.shared.token = response.appPassword UserSettings.shared.token = response.appPassword
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
@@ -181,37 +293,72 @@ struct V2LoginView: View {
// Login WebView logic struct LoginBrowserView: UIViewControllerRepresentable {
let authURL: URL
let callbackURLScheme: String
var completion: (Result<URL, Error>) -> Void
struct WebViewSheet: View { func makeUIViewController(context: Context) -> UIViewController {
@Environment(\.dismiss) var dismiss UIViewController()
@State var url: String }
var body: some View { func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
NavigationStack {
WebView(url: URL(string: url)!) if !context.coordinator.sessionStarted {
.navigationTitle("Nextcloud Login") context.coordinator.sessionStarted = true
.navigationBarTitleDisplayMode(.inline)
.toolbar { let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
ToolbarItem(placement: .topBarTrailing) { context.coordinator.sessionStarted = false // Reset for potential retry
Button("Done") { if let callbackURL = callbackURL {
dismiss() completion(.success(callbackURL))
} } else if let error = error {
} completion(.failure(error))
} else {
// Handle unexpected nil URL and error
completion(.failure(LoginError.unknownError))
} }
}
session.presentationContextProvider = context.coordinator
session.prefersEphemeralWebBrowserSession = false
session.start()
}
}
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
var parent: LoginBrowserView
var sessionStarted: Bool = false // Prevent starting multiple sessions
init(_ parent: LoginBrowserView) {
self.parent = parent
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
return windowScene.windows.first!
}
return ASPresentationAnchor()
}
}
enum LoginError: Error, LocalizedError {
case unknownError
var errorDescription: String? {
switch self {
case .unknownError: return "An unknown error occurred during login."
}
} }
} }
} }
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView { #Preview {
return WKWebView() V2LoginView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
let request = URLRequest(url: url)
uiView.load(request)
}
} }

View File

@@ -8,8 +8,9 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
struct RecipeCardView: View { struct RecipeCardView: View {
@EnvironmentObject var appState: AppState //@EnvironmentObject var appState: AppState
@State var recipe: Recipe @State var recipe: Recipe
@State var recipeThumb: UIImage? @State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil @State var isDownloaded: Bool? = nil
@@ -49,6 +50,7 @@ struct RecipeCardView: View {
.background(Color.backgroundHighlight) .background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipShape(RoundedRectangle(cornerRadius: 17))
.task { .task {
/*
recipeThumb = await appState.getImage( recipeThumb = await appState.getImage(
id: recipe.recipe_id, id: recipe.recipe_id,
size: .THUMB, size: .THUMB,
@@ -58,14 +60,76 @@ struct RecipeCardView: View {
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id) recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
} }
isDownloaded = recipe.storedLocally isDownloaded = recipe.storedLocally
*/
} }
.refreshable { .refreshable {
/*
recipeThumb = await appState.getImage( recipeThumb = await appState.getImage(
id: recipe.recipe_id, id: recipe.recipe_id,
size: .THUMB, size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
)*/
}
.frame(height: 80)
}
}
/*
struct RecipeCardView: View {
@State var state: AccountState
@State var recipe: RecipeStub
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
var body: some View {
HStack {
if let recipeThumb = recipeThumb {
Image(uiImage: recipeThumb)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
} else {
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
.background(Color("ncblue"))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
}
Text(recipe.name)
.font(.headline)
.padding(.leading, 4)
Spacer()
if let isDownloaded = isDownloaded {
VStack {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
Spacer()
}
}
}
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17))
.task {
recipeThumb = await state.getImage(
id: recipe.id,
size: .THUMB
)
isDownloaded = recipe.storedLocally
}
.refreshable {
recipeThumb = await state.getImage(
id: recipe.id,
size: .THUMB
) )
} }
.frame(height: 80) .frame(height: 80)
} }
} }
*/

View File

@@ -7,17 +7,63 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
struct RecipeListView: View {
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe]
@Binding var selectedRecipe: Recipe?
@Binding var selectedCategory: String?
init(selectedCategory: Binding<String?>, selectedRecipe: Binding<Recipe?>) {
var predicate: Predicate<Recipe>? = nil
if let category = selectedCategory.wrappedValue, category != "*" {
predicate = #Predicate<Recipe> {
$0.category == category
}
}
_recipes = Query(filter: predicate, sort: \.name)
_selectedRecipe = selectedRecipe
_selectedCategory = selectedCategory
}
var body: some View {
List(selection: $selectedRecipe) {
ForEach(recipes) { recipe in
RecipeCardView(recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink(value: recipe) {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
}
.listStyle(.plain)
.navigationTitle("Recipes")
.toolbar {
}
}
}
/*
struct RecipeListView: View { struct RecipeListView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryList
@State var categoryName: String @State var categoryName: String
@State var searchText: String = "" @State var searchText: String = ""
@Binding var showEditView: Bool @Binding var showEditView: Bool
@State var selectedRecipe: Recipe? = nil @State var selectedRecipe: CookbookApiRecipeV1? = nil
var body: some View { var body: some View {
Group { Group {
@@ -57,7 +103,7 @@ struct RecipeListView: View {
.searchable(text: $searchText, prompt: "Search recipes/keywords") .searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
@@ -65,6 +111,7 @@ struct RecipeListView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
print("Add new recipe")
showEditView = true showEditView = true
} label: { } label: {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
@@ -85,7 +132,7 @@ struct RecipeListView: View {
} }
} }
func recipesFiltered() -> [Recipe] { func recipesFiltered() -> [CookbookApiRecipeV1] {
guard let recipes = appState.recipes[categoryName] else { return [] } guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes } guard searchText != "" else { return recipes }
return recipes.filter { recipe in return recipes.filter { recipe in
@@ -94,3 +141,86 @@ struct RecipeListView: View {
} }
} }
} }
*/
/*
struct RecipeListView: View {
@Bindable var cookbookState: CookbookState
@State var selectedCategory: String
@State var searchText: String = ""
@Binding var showEditView: Bool
var body: some View {
Group {
let recipes = recipesFiltered()
if !recipes.isEmpty {
List(recipesFiltered(), selection: $cookbookState.selectedAccountState.selectedRecipe) { recipe in
RecipeCardView(state: cookbookState.selectedAccountState, recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink {
RecipeView(viewModel: RecipeView.ViewModel(recipeStub: recipe))
.environment(cookbookState)
} label: {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
.listStyle(.plain)
} else {
VStack {
Text("There are no recipes in this cookbook!")
Button {
Task {
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: selectedCategory)
}
} label: {
Text("Refresh")
.bold()
}
.buttonStyle(.bordered)
}.padding()
}
}
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(selectedCategory == "*" ? String(localized: "Other") : selectedCategory)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Add new recipe")
showEditView = true
} label: {
Image(systemName: "plus.circle.fill")
}
}
}
.task {
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
named: selectedCategory
)
}
.refreshable {
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
named: selectedCategory
)
}
}
func recipesFiltered() -> [RecipeStub] {
guard let recipes = cookbookState.selectedAccountState.recipeStubs[selectedCategory] else { return [] }
guard searchText != "" else { return recipes }
return recipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
}
}
}
*/

View File

@@ -6,18 +6,17 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
struct RecipeView: View { struct RecipeView: View {
@EnvironmentObject var appState: AppState @Bindable var recipe: Recipe
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel @StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero @GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat { var imageHeight: CGFloat {
if let image = viewModel.recipeImage { if let recipeImage = recipe.image, let image = recipeImage.image {
return image.size.height < 350 ? image.size.height : 350 return image.size.height < 350 ? image.size.height : 350
} }
return 200 return 200
@@ -34,8 +33,8 @@ struct RecipeView: View {
coordinateSpace: CoordinateSpaces.scrollView, coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight defaultHeight: imageHeight
) { ) {
if let recipeImage = viewModel.recipeImage { if let recipeImage = recipe.image, let image = recipeImage.image {
Image(uiImage: recipeImage) Image(uiImage: image)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(maxHeight: imageHeight + 200) .frame(maxHeight: imageHeight + 200)
@@ -55,15 +54,12 @@ struct RecipeView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if viewModel.editMode { if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe) //RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
} //RecipeMetadataSection(viewModel: viewModel)
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
} }
HStack { HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name") EditableText(text: $recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title) .font(.title)
.bold() .bold()
@@ -75,36 +71,37 @@ struct RecipeView: View {
} }
}.padding([.top, .horizontal]) }.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode { if recipe.recipeDescription != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical) EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium) .fontWeight(.medium)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 2) .padding(.top, 2)
} }
// Recipe Body Section // Recipe Body Section
RecipeDurationSection(viewModel: viewModel) RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode)
Divider() Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel) if(!recipe.ingredients.isEmpty || viewModel.editMode) {
RecipeIngredientSection(recipe: recipe, editMode: $viewModel.editMode, presentIngredientEditView: $viewModel.presentIngredientEditView)
} }
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) { if(!recipe.instructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel) RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView)
} }
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) { if(!recipe.tools.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel) RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView)
} }
RecipeNutritionSection(viewModel: viewModel) RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode)
} }
if !viewModel.editMode { if !viewModel.editMode {
Divider() Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel) //RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel) MoreInformationSection(recipe: recipe)
} }
} }
} }
@@ -116,21 +113,21 @@ struct RecipeView: View {
.ignoresSafeArea(.container, edges: .top) .ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar) .toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "") .navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar { .toolbar {
RecipeViewToolBar(viewModel: viewModel) RecipeViewToolBar(viewModel: viewModel)
} }
.sheet(isPresented: $viewModel.presentShareSheet) { .sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), /*ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage, recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet) presentShareSheet: $viewModel.presentShareSheet)*/
} }
.sheet(isPresented: $viewModel.presentInstructionEditView) { .sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView( EditableListView(
isPresented: $viewModel.presentInstructionEditView, isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions, items: $recipe.instructions,
title: "Instructions", title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.", emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction", titleKey: "Instruction",
@@ -140,7 +137,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentIngredientEditView) { .sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView( EditableListView(
isPresented: $viewModel.presentIngredientEditView, isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.observableRecipeDetail.recipeIngredient, items: $recipe.ingredients,
title: "Ingredients", title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬", emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient", titleKey: "Ingredient",
@@ -150,7 +147,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentToolEditView) { .sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView( EditableListView(
isPresented: $viewModel.presentToolEditView, isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool, items: $recipe.tools,
title: "Tools", title: "Tools",
emptyListText: "List your tools here. 🍴", emptyListText: "List your tools here. 🍴",
titleKey: "Tool", titleKey: "Tool",
@@ -159,13 +156,14 @@ struct RecipeView: View {
} }
.task { .task {
/*
// Load recipe detail // Load recipe detail
if !viewModel.newRecipe { if !viewModel.newRecipe {
// For existing recipes, load the recipeDetail and image // For existing recipes, load the recipeDetail and image
let recipeDetail = await appState.getRecipe( let recipeDetail = await appState.getRecipe(
id: viewModel.recipe.recipe_id, id: viewModel.recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? RecipeDetail.error ) ?? CookbookApiRecipeDetailV1.error
viewModel.setupView(recipeDetail: recipeDetail) viewModel.setupView(recipeDetail: recipeDetail)
// Show download badge // Show download badge
@@ -183,10 +181,10 @@ struct RecipeView: View {
} else { } else {
// Prepare view for a new recipe // Prepare view for a new recipe
viewModel.setupView(recipeDetail: RecipeDetail()) viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
viewModel.editMode = true viewModel.editMode = true
viewModel.isDownloaded = false viewModel.isDownloaded = false
} }*/
} }
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in ForEach(viewModel.alertType.alertButtons) { buttonType in
@@ -218,13 +216,14 @@ struct RecipeView: View {
UIApplication.shared.isIdleTimerDisabled = false UIApplication.shared.isIdleTimerDisabled = false
} }
.onChange(of: viewModel.editMode) { newValue in .onChange(of: viewModel.editMode) { newValue in
/*
if newValue && appState.allKeywords.isEmpty { if newValue && appState.allKeywords.isEmpty {
Task { Task {
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
a.recipe_count > b.recipe_count a.recipe_count > b.recipe_count
}) })
} }
} }*/
} }
} }
@@ -232,9 +231,8 @@ struct RecipeView: View {
// MARK: - RecipeView ViewModel // MARK: - RecipeView ViewModel
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail() @Published var recipe: Recipe
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
@Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false @Published var editMode: Bool = false
@Published var showTitle: Bool = false @Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil @Published var isDownloaded: Bool? = nil
@@ -245,7 +243,6 @@ struct RecipeView: View {
@Published var presentIngredientEditView: Bool = false @Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false @Published var presentToolEditView: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil var sharedURL: URL? = nil
var newRecipe: Bool = false var newRecipe: Bool = false
@@ -261,20 +258,7 @@ struct RecipeView: View {
init() { init() {
self.newRecipe = true self.newRecipe = true
self.recipe = Recipe( self.recipe = Recipe()
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
imagePlaceholderUrl: "",
recipe_id: 0)
}
// View setup
func setupView(recipeDetail: RecipeDetail) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
} }
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) { func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
@@ -286,32 +270,36 @@ struct RecipeView: View {
} }
/*
extension RecipeView { extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? { func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url) let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe { if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe) viewModel.setupView(recipeDetail: scrapedRecipe)
// Fetch the image from the server if the import created a recipe with a valid id
if let recipeId = Int(scrapedRecipe.id), recipeId > 0 {
viewModel.recipeImage = await appState.getImage(
id: recipeId,
size: .FULL,
fetchMode: .onlyServer
)
}
return nil return nil
} }
return error
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
} }
} }
*/
// MARK: - Tool Bar // MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent { struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
@@ -367,7 +355,7 @@ struct RecipeViewToolBar: ToolbarContent {
} }
Button { Button {
Logger.view.debug("Sharing recipe ...") print("Sharing recipe ...")
viewModel.presentShareSheet = true viewModel.presentShareSheet = true
} label: { } label: {
Label("Share Recipe", systemImage: "square.and.arrow.up") Label("Share Recipe", systemImage: "square.and.arrow.up")
@@ -381,76 +369,43 @@ struct RecipeViewToolBar: ToolbarContent {
} }
func handleUpload() async { func handleUpload() async {
/*
if viewModel.newRecipe { if viewModel.newRecipe {
// Check if the recipe was already created on the server by import print("Uploading new recipe.")
let importedId = Int(viewModel.observableRecipeDetail.id) ?? 0 if let recipeValidationError = recipeValid() {
let alreadyCreatedByImport = importedId > 0 viewModel.presentAlert(recipeValidationError)
return
if alreadyCreatedByImport { }
Logger.view.debug("Uploading changes to imported recipe (id: \(importedId)).")
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) {
if let alert { viewModel.presentAlert(alert)
viewModel.presentAlert(alert) return
return
}
} else {
Logger.view.debug("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
let (newId, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true)
if let alert {
viewModel.presentAlert(alert)
return
}
if let newId {
viewModel.observableRecipeDetail.id = String(newId)
}
} }
} else { } else {
Logger.view.debug("Uploading changed recipe.") print("Uploading changed recipe.")
guard let _ = Int(viewModel.observableRecipeDetail.id) else { guard let _ = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return return
} }
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) {
if let alert {
viewModel.presentAlert(alert) viewModel.presentAlert(alert)
return return
} }
} }
await appState.getCategories() await appState.getCategories()
let newCategory = viewModel.observableRecipeDetail.recipeCategory.isEmpty ? "*" : viewModel.observableRecipeDetail.recipeCategory await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer)
let oldCategory = viewModel.recipeDetail.recipeCategory.isEmpty ? "*" : viewModel.recipeDetail.recipeCategory
await appState.getCategory(named: newCategory, fetchMode: .preferServer)
if oldCategory != newCategory {
await appState.getCategory(named: oldCategory, fetchMode: .preferServer)
}
if let id = Int(viewModel.observableRecipeDetail.id) { if let id = Int(viewModel.observableRecipeDetail.id) {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true) let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
// Fetch the image after upload so it displays in view mode
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
// Update recipe reference so the view tracks the server-assigned id
viewModel.recipe = Recipe(
name: viewModel.observableRecipeDetail.name,
keywords: viewModel.observableRecipeDetail.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
imageUrl: viewModel.observableRecipeDetail.imageUrl,
imagePlaceholderUrl: "",
recipe_id: id
)
} }
viewModel.newRecipe = false
viewModel.editMode = false viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS) viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
*/
} }
func handleDelete() async { func handleDelete() async {
/*
let category = viewModel.observableRecipeDetail.recipeCategory let category = viewModel.observableRecipeDetail.recipeCategory
guard let id = Int(viewModel.observableRecipeDetail.id) else { guard let id = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
@@ -464,11 +419,13 @@ struct RecipeViewToolBar: ToolbarContent {
await appState.getCategory(named: category, fetchMode: .preferServer) await appState.getCategory(named: category, fetchMode: .preferServer)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS) viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss() dismiss()
*/
} }
func recipeValid() -> RecipeAlert? { func recipeValid() -> RecipeAlert? {
/*
// Check if the recipe has a name // Check if the recipe has a name
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" { if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE return RecipeAlert.NO_TITLE
} }
@@ -486,8 +443,437 @@ struct RecipeViewToolBar: ToolbarContent {
} }
} }
} }
*/
return nil return nil
} }
} }
/*
struct RecipeView: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let image = viewModel.recipeImage {
return image.size.height < 350 ? image.size.height : 350
}
return 200
}
private enum CoordinateSpaces {
case scrollView
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.recipe.description != "" || viewModel.editMode {
EditableText(text: $viewModel.recipe.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.recipe.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.recipe.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.recipe.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.recipe.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.recipe.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.recipe.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.recipe.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// Load recipe detail
if let recipeStub = viewModel.recipeStub {
// For existing recipes, load the recipeDetail and image
let recipe = await cookbookState.selectedAccountState.getRecipe(
id: recipeStub.id
) ?? Recipe()
viewModel.recipe = recipe
// Show download badge
/*if viewModel.recipeStub!.storedLocally == nil {
viewModel.recipeStub?.storedLocally = cookbookState.selectedAccountState.recipeDetailExists(
recipeId: viewModel.recipe.recipe_id
)
}
viewModel.isDownloaded = viewModel.recipeStub!.storedLocally
*/
// Load recipe image
viewModel.recipeImage = await cookbookState.selectedAccountState.getImage(
id: recipeStub.id,
size: .FULL
)
} else {
// Prepare view for a new recipe
viewModel.editMode = true
viewModel.isDownloaded = false
viewModel.recipe = Recipe()
}
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await viewModel.alertAction()
}
}
} else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await viewModel.alertAction()
}
}
}
}
} message: {
Text(viewModel.alertType.localizedDescription)
}
.onAppear {
if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
.onChange(of: viewModel.editMode) { newValue in
if newValue && cookbookState.selectedAccountState.keywords.isEmpty {
Task {
if let keywords = await cookbookState.selectedAccountState.getTags()?.sorted(by: { a, b in
a.recipe_count > b.recipe_count
}) {
cookbookState.selectedAccountState.keywords = keywords
}
}
}
}
}
// MARK: - RecipeView ViewModel
@Observable class ViewModel {
var recipeImage: UIImage? = nil
var editMode: Bool = false
var showTitle: Bool = false
var isDownloaded: Bool? = nil
var importUrl: String = ""
var presentShareSheet: Bool = false
var presentInstructionEditView: Bool = false
var presentIngredientEditView: Bool = false
var presentToolEditView: Bool = false
var recipeStub: RecipeStub? = nil
var recipe: Recipe = Recipe()
var sharedURL: URL? = nil
var newRecipe: Bool = false
// Alerts
var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(recipeStub: RecipeStub) {
self.recipeStub = recipeStub
}
init() {
self.newRecipe = true
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
alertType = type
alertAction = action
presentAlert = true
}
}
}
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
/*let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
return nil
}*/
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.recipe = scrapedRecipe.toRecipe()
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
await handleUpload()
}
} label: {
if viewModel.newRecipe {
Text("Upload Recipe")
} else {
Text("Upload Changes")
}
}
}
} else {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
viewModel.editMode = true
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
print("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
func handleUpload() async {
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await cookbookState.selectedAccountState.postRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
} else {
print("Uploading changed recipe.")
if let alert = await cookbookState.selectedAccountState.updateRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: viewModel.recipe.recipeCategory)
let _ = await cookbookState.selectedAccountState.getRecipe(id: viewModel.recipe.id)
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.recipe.recipeCategory
if let alert = await cookbookState.selectedAccountState.deleteRecipe(id: viewModel.recipe.id) {
viewModel.presentAlert(alert)
return
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: category)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in cookbookState.selectedAccountState.recipeStubs.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.recipe.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}
*/

View File

@@ -11,17 +11,18 @@ import SwiftUI
// MARK: - RecipeView Duration Section // MARK: - RecipeView Duration Section
struct RecipeDurationSection: View { struct RecipeDurationSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@State var presentPopover: Bool = false @State var presentPopover: Bool = false
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation")) DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking")) DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time")) DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time"))
} }
if viewModel.editMode { if editMode {
Button { Button {
presentPopover.toggle() presentPopover.toggle()
} label: { } label: {
@@ -34,9 +35,9 @@ struct RecipeDurationSection: View {
.padding() .padding()
.popover(isPresented: $presentPopover) { .popover(isPresented: $presentPopover) {
EditableDurationView( EditableDurationView(
prepTime: viewModel.observableRecipeDetail.prepTime, prepTime: recipe.prepTimeDurationComponent,
cookTime: viewModel.observableRecipeDetail.cookTime, cookTime: recipe.cookTimeDurationComponent,
totalTime: viewModel.observableRecipeDetail.totalTime totalTime: recipe.totalTimeDurationComponent
) )
} }
} }
@@ -94,10 +95,10 @@ fileprivate struct EditableDurationView: View {
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent) TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
} }
.padding() .padding()
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() } .onChange(of: prepTime.hourComponent) { updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() } .onChange(of: prepTime.minuteComponent) { updateTotalTime() }
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() } .onChange(of: cookTime.hourComponent) { updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() } .onChange(of: cookTime.minuteComponent) { updateTotalTime() }
} }
} }
@@ -142,3 +143,5 @@ fileprivate struct TimePickerView: View {
.padding() .padding()
} }
} }

View File

@@ -69,7 +69,7 @@ struct EditableListView: View {
@State var axis: Axis = .vertical @State var axis: Axis = .vertical
var body: some View { var body: some View {
NavigationStack { NavigationView {
ZStack { ZStack {
List { List {
if items.isEmpty { if items.isEmpty {
@@ -83,12 +83,12 @@ struct EditableListView: View {
.onDelete(perform: deleteItem) .onDelete(perform: deleteItem)
.onMove(perform: moveItem) .onMove(perform: moveItem)
.scrollDismissesKeyboard(.immediately) .scrollDismissesKeyboard(.immediately)
} }
} }
VStack { VStack {
Spacer() Spacer()
Button { Button {
addItem() addItem()
} label: { } label: {
@@ -101,15 +101,12 @@ struct EditableListView: View {
.padding() .padding()
} }
} }
.navigationTitle(title) .navigationBarTitle(title, displayMode: .inline)
.navigationBarTitleDisplayMode(.inline) .navigationBarItems(
.toolbar { trailing: Button(action: { isPresented = false }) {
ToolbarItem(placement: .topBarTrailing) { Text("Done")
Button(action: { isPresented = false }) {
Text("Done")
}
} }
} )
.environment(\.editMode, .constant(.active)) .environment(\.editMode, .constant(.active))
} }
} }

View File

@@ -10,9 +10,9 @@ import SwiftUI
// MARK: - RecipeView Import Section // MARK: - RecipeView Import Section
/*
struct RecipeImportSection: View { struct RecipeImportSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @State var viewModel: RecipeView.ViewModel
var importRecipe: (String) async -> UserAlert? var importRecipe: (String) async -> UserAlert?
var body: some View { var body: some View {
@@ -49,4 +49,4 @@ struct RecipeImportSection: View {
.padding(.top, 5) .padding(.top, 5)
} }
} }
*/

View File

@@ -7,31 +7,42 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
// MARK: - RecipeView Ingredients Section // MARK: - RecipeView Ingredients Section
struct RecipeIngredientSection: View { struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList @Environment(\.modelContext) var modelContext
@ObservedObject var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentIngredientEditView: Bool
@State var recipeGroceries: RecipeGroceries? = nil
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Button { Button {
withAnimation { withAnimation {
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) { /*
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id) if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
} else { } else {
groceryList.addItems( cookbookState.groceryList.addItems(
viewModel.observableRecipeDetail.recipeIngredient, viewModel.recipe.recipeIngredient,
toRecipe: viewModel.observableRecipeDetail.id, toRecipe: viewModel.recipe.id,
recipeName: viewModel.observableRecipeDetail.name recipeName: viewModel.recipe.name
) )
} }
*/
} }
} label: { } label: {
Image(systemName: "storefront") if #available(iOS 17.0, *) {
}.disabled(viewModel.editMode) Image(systemName: "storefront")
} else {
Image(systemName: "heart.text.square")
}
}.disabled(editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients")) SecondaryLabel(text: LocalizedStringKey("Ingredients"))
@@ -41,26 +52,30 @@ struct RecipeIngredientSection: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.bold() .bold()
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier) ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier)
} }
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix], ForEach(0..<recipe.ingredients.count, id: \.self) { ix in
servings: $viewModel.observableRecipeDetail.ingredientMultiplier, /*IngredientListItem(
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield), ingredient: $recipe.recipeIngredient[ix],
recipeId: viewModel.observableRecipeDetail.id servings: $recipe.ingredientMultiplier,
recipeYield: Double(recipe.recipeYield),
recipeId: recipe.id
) { ) {
groceryList.addItem( /*
viewModel.observableRecipeDetail.recipeIngredient[ix], cookbookState.groceryList.addItem(
toRecipe: viewModel.observableRecipeDetail.id, recipe.recipeIngredient[ix],
recipeName: viewModel.observableRecipeDetail.name toRecipe: recipe.id,
) recipeName: recipe.name
)*/
} }
.padding(4) .padding(4)*/
Text(recipe.ingredients[ix])
} }
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) { if recipe.ingredientMultiplier != Double(recipe.yield) {
HStack() { HStack() {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -69,9 +84,9 @@ struct RecipeIngredientSection: View {
}.padding(.top) }.padding(.top)
} }
if viewModel.editMode { if editMode {
Button { Button {
viewModel.presentIngredientEditView.toggle() presentIngredientEditView.toggle()
} label: { } label: {
Text("Edit") Text("Edit")
} }
@@ -79,19 +94,80 @@ struct RecipeIngredientSection: View {
} }
} }
.padding() .padding()
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier) .animation(.easeInOut, value: recipe.ingredientMultiplier)
}
func toggleAllGroceryItems(_ itemNames: [String], inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete category if it exists
modelContext.delete(existingCategory)
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
newCategory.items.append(newItem)
}
try modelContext.save()
}
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
func toggleGroceryItem(_ itemName: String, inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete item if it exists
if existingCategory.items.contains(where: { $0.name == itemName }) {
existingCategory.items.removeAll { $0.name == itemName }
// Delete category if empty
if existingCategory.items.isEmpty {
modelContext.delete(existingCategory)
}
} else {
existingCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
} else {
// Add the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add the item to the new category
newCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
} }
} }
// MARK: - RecipeIngredientSection List Item // MARK: - RecipeIngredientSection List Item
/*
fileprivate struct IngredientListItem: View { fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList @Environment(\.modelContext) var modelContext
@Bindable var recipeGroceries: RecipeGroceries
@Binding var ingredient: String @Binding var ingredient: String
@Binding var servings: Double @Binding var servings: Double
@State var recipeYield: Double @State var recipeYield: Double
@State var recipeId: String @State var recipeId: String
let addToGroceryListAction: () -> Void
@State var modifiedIngredient: AttributedString = "" @State var modifiedIngredient: AttributedString = ""
@State var isSelected: Bool = false @State var isSelected: Bool = false
@@ -106,9 +182,15 @@ fileprivate struct IngredientListItem: View {
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
if groceryList.containsItem(at: recipeId, item: ingredient) { if recipeGroceries.items.contains(ingredient) {
Image(systemName: "storefront") if #available(iOS 17.0, *) {
.foregroundStyle(Color.green) Image(systemName: "storefront")
.foregroundStyle(Color.green)
} else {
Image(systemName: "heart.text.square")
.foregroundStyle(Color.green)
}
} else if isSelected { } else if isSelected {
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
} else { } else {
@@ -130,11 +212,11 @@ fileprivate struct IngredientListItem: View {
} }
Spacer() Spacer()
} }
.onChange(of: servings) { newServings in .onChange(of: servings) { _, newServings in
if recipeYield == 0 { if recipeYield == 0 {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings) modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings)
} else { } else {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield) modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings/recipeYield)
} }
} }
.foregroundStyle(isSelected ? Color.secondary : Color.primary) .foregroundStyle(isSelected ? Color.secondary : Color.primary)
@@ -158,8 +240,8 @@ fileprivate struct IngredientListItem: View {
.onEnded { gesture in .onEnded { gesture in
withAnimation { withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if groceryList.containsItem(at: recipeId, item: ingredient) { if recipeGroceries.items.contains(ingredient) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId) cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else { } else {
addToGroceryListAction() addToGroceryListAction()
} }
@@ -172,7 +254,7 @@ fileprivate struct IngredientListItem: View {
) )
} }
} }
*/
struct ServingPickerView: View { struct ServingPickerView: View {
@@ -199,9 +281,12 @@ struct ServingPickerView: View {
.bold() .bold()
} }
} }
.onChange(of: selectedServingSize) { newValue in .onChange(of: selectedServingSize) { _, newValue in
if newValue < 0 { selectedServingSize = 0 } if newValue < 0 { selectedServingSize = 0 }
else if newValue > 100 { selectedServingSize = 100 } else if newValue > 100 { selectedServingSize = 100 }
} }
} }
} }

View File

@@ -11,20 +11,24 @@ import SwiftUI
// MARK: - RecipeView Instructions Section // MARK: - RecipeView Instructions Section
struct RecipeInstructionSection: View { struct RecipeInstructionSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentInstructionEditView: Bool
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
SecondaryLabel(text: LocalizedStringKey("Instructions")) SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer() Spacer()
} }
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in ForEach(recipe.instructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1) RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1)
} }
if viewModel.editMode { if editMode {
Button { Button {
viewModel.presentInstructionEditView.toggle() presentInstructionEditView.toggle()
} label: { } label: {
Text("Edit") Text("Edit")
} }
@@ -32,11 +36,10 @@ struct RecipeInstructionSection: View {
} }
} }
.padding() .padding()
} }
} }
// MARK: - Preview
fileprivate struct RecipeInstructionListItem: View { fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String @Binding var instruction: String
@@ -57,3 +60,44 @@ fileprivate struct RecipeInstructionListItem: View {
} }
} }
struct RecipeInstructionSection_Previews: PreviewProvider {
static var previews: some View {
// Create a mock recipe
@State var mockRecipe = createRecipe()
// Create mock state variables for the @Binding properties
@State var mockEditMode = true
@State var mockPresentInstructionEditView = false
// Provide the mock data to the view
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Edit Mode")
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Read Only")
.environment(\.editMode, .constant(.inactive))
}
static func createRecipe() -> Recipe {
let recipe = Recipe()
recipe.name = "Mock Recipe"
recipe.instructions = [
"Step 1: Gather all ingredients and equipment.",
"Step 2: Preheat oven to 180°C (350°F) and prepare baking dish.",
"Step 3: Combine dry ingredients in a large bowl and mix thoroughly.",
"Step 4: In a separate bowl, whisk wet ingredients until smooth.",
"Step 5: Gradually add wet ingredients to dry ingredients, mixing until just combined. Do not overmix.",
"Step 6: Pour the mixture into the prepared baking dish and bake for 30-35 minutes, or until golden brown and a toothpick inserted into the center comes out clean.",
"Step 7: Let cool before serving. Enjoy!"
]
return recipe
}
}

View File

@@ -9,16 +9,16 @@ import Foundation
import SwiftUI import SwiftUI
// MARK: - RecipeView Keyword Section // MARK: - RecipeView Keyword Section
/*
struct RecipeKeywordSection: View { struct RecipeKeywordSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @State var viewModel: RecipeView.ViewModel
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ] let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group { Group {
if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode { if !viewModel.recipe.keywords.isEmpty && !viewModel.editMode {
RecipeListSection(list: $viewModel.observableRecipeDetail.keywords) RecipeListSection(list: $viewModel.recipe.keywords)
} else { } else {
Text(LocalizedStringKey("No keywords.")) Text(LocalizedStringKey("No keywords."))
} }
@@ -189,3 +189,4 @@ struct KeywordPickerView_Previews: PreviewProvider {
} }
} }
*/

View File

@@ -9,14 +9,14 @@ import Foundation
import SwiftUI import SwiftUI
// MARK: - Recipe Metadata Section // MARK: - Recipe Metadata Section
/*
struct RecipeMetadataSection: View { struct RecipeMetadataSection: View {
@EnvironmentObject var appState: AppState @Environment(CookbookState.self) var cookbookState
@ObservedObject var viewModel: RecipeView.ViewModel @State var viewModel: RecipeView.ViewModel
@State var keywords: [RecipeKeyword] = [] @State var keywords: [RecipeKeyword] = []
var categories: [String] { var categories: [String] {
appState.categories.map({ category in category.name }) cookbookState.selectedAccountState.categories.map({ category in category.name })
} }
@State var presentKeywordSheet: Bool = false @State var presentKeywordSheet: Bool = false
@@ -28,11 +28,11 @@ struct RecipeMetadataSection: View {
// Category // Category
SecondaryLabel(text: "Category") SecondaryLabel(text: "Category")
HStack { HStack {
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory) TextField("Category", text: $viewModel.recipe.recipeCategory)
.lineLimit(1) .lineLimit(1)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) { Picker("Choose", selection: $viewModel.recipe.recipeCategory) {
Text("").tag("") Text("").tag("")
ForEach(categories, id: \.self) { item in ForEach(categories, id: \.self) { item in
Text(item) Text(item)
@@ -45,10 +45,10 @@ struct RecipeMetadataSection: View {
// Keywords // Keywords
SecondaryLabel(text: "Keywords") SecondaryLabel(text: "Keywords")
if !viewModel.observableRecipeDetail.keywords.isEmpty { if !viewModel.recipe.keywords.isEmpty {
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack { HStack {
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in ForEach(viewModel.recipe.keywords, id: \.self) { keyword in
Text(keyword) Text(keyword)
.padding(5) .padding(5)
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1))) .background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
@@ -70,11 +70,11 @@ struct RecipeMetadataSection: View {
Button { Button {
presentServingsPopover.toggle() presentServingsPopover.toggle()
} label: { } label: {
Text("\(viewModel.observableRecipeDetail.recipeYield) Serving(s)") Text("\(viewModel.recipe.recipeYield) Serving(s)")
.lineLimit(1) .lineLimit(1)
} }
.popover(isPresented: $presentServingsPopover) { .popover(isPresented: $presentServingsPopover) {
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings") PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.recipe.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
} }
} }
} }
@@ -82,7 +82,7 @@ struct RecipeMetadataSection: View {
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1))) .background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
.padding([.horizontal, .bottom], 5) .padding([.horizontal, .bottom], 5)
.sheet(isPresented: $presentKeywordSheet) { .sheet(isPresented: $presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords) KeywordPickerView(title: "Keywords", searchSuggestions: cookbookState.selectedAccountState.keywords, selection: $viewModel.recipe.keywords)
} }
} }
} }
@@ -121,27 +121,27 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
.padding() .padding()
} }
} }
*/
// MARK: - RecipeView More Information Section // MARK: - RecipeView More Information Section
struct MoreInformationSection: View { struct MoreInformationSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let dateCreated = viewModel.recipeDetail.dateCreated { if let dateCreated = recipe.dateCreated {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")") Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
} }
if let dateModified = viewModel.recipeDetail.dateModified { if let dateModified = recipe.dateModified {
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")") Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
} }
if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) { if recipe.url != "", let url = URL(string: recipe.url ?? "") {
HStack(alignment: .top) { HStack(alignment: .top) {
Text("URL:") Text("URL:")
Link(destination: url) { Link(destination: url) {
Text(viewModel.observableRecipeDetail.url) Text(recipe.url ?? "")
} }
} }
} }

View File

@@ -11,12 +11,13 @@ import SwiftUI
// MARK: - RecipeView Nutrition Section // MARK: - RecipeView Nutrition Section
struct RecipeNutritionSection: View { struct RecipeNutritionSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if viewModel.editMode { if editMode {
ForEach(Nutrition.allCases, id: \.self) { nutrition in ForEach(Nutrition.allCases, id: \.self) { nutrition in
HStack { HStack {
Text(nutrition.localizedDescription) Text(nutrition.localizedDescription)
@@ -28,7 +29,7 @@ struct RecipeNutritionSection: View {
} else if !nutritionEmpty() { } else if !nutritionEmpty() {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in ForEach(Nutrition.allCases, id: \.self) { nutrition in
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey { if let value = recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
HStack(alignment: .top) { HStack(alignment: .top) {
Text("\(nutrition.localizedDescription): \(value)") Text("\(nutrition.localizedDescription): \(value)")
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@@ -43,7 +44,7 @@ struct RecipeNutritionSection: View {
} }
} title: { } title: {
HStack { HStack {
if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] { if let servingSize = recipe.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))") SecondaryLabel(text: "Nutrition (\(servingSize))")
} else { } else {
SecondaryLabel(text: LocalizedStringKey("Nutrition")) SecondaryLabel(text: LocalizedStringKey("Nutrition"))
@@ -56,17 +57,18 @@ struct RecipeNutritionSection: View {
func binding(for key: String) -> Binding<String> { func binding(for key: String) -> Binding<String> {
Binding( Binding(
get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] }, get: { recipe.nutrition[key, default: ""] },
set: { viewModel.observableRecipeDetail.nutrition[key] = $0 } set: { recipe.nutrition[key] = $0 }
) )
} }
func nutritionEmpty() -> Bool { func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases { for nutrition in Nutrition.allCases {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] { if let value = recipe.nutrition[nutrition.dictKey] {
return false return false
} }
} }
return true return true
} }
} }

View File

@@ -11,7 +11,9 @@ import SwiftUI
// MARK: - RecipeView Tool Section // MARK: - RecipeView Tool Section
struct RecipeToolSection: View { struct RecipeToolSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentToolEditView: Bool
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -20,11 +22,11 @@ struct RecipeToolSection: View {
Spacer() Spacer()
} }
RecipeListSection(list: $viewModel.observableRecipeDetail.tool) RecipeListSection(list: $recipe.tools)
if viewModel.editMode { if editMode {
Button { Button {
viewModel.presentToolEditView.toggle() presentToolEditView.toggle()
} label: { } label: {
Text("Edit") Text("Edit")
} }
@@ -35,3 +37,5 @@ struct RecipeToolSection: View {
} }

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct ShareView: View { struct ShareView: View {
@State var recipeDetail: RecipeDetail @State var recipeDetail: CookbookApiRecipeDetailV1
@State var recipeImage: UIImage? @State var recipeImage: UIImage?
@Binding var presentShareSheet: Bool @Binding var presentShareSheet: Bool

View File

@@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import Combine import Combine
import AVFoundation import AVFoundation
@@ -56,13 +55,8 @@ struct TimerView: View {
.bold() .bold()
.padding() .padding()
.background { .background {
if #available(iOS 26, *) { RoundedRectangle(cornerRadius: 20)
Color.clear .foregroundStyle(.ultraThickMaterial)
.glassEffect(.regular, in: .rect(cornerRadius: 20))
} else {
RoundedRectangle(cornerRadius: 20)
.foregroundStyle(.ultraThickMaterial)
}
} }
} }
} }
@@ -153,7 +147,7 @@ extension RecipeTimer {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
} catch { } catch {
Logger.view.error("Failed to set audio session category. Error: \(error)") print("Failed to set audio session category. Error: \(error)")
} }
} }
@@ -164,7 +158,7 @@ extension RecipeTimer {
audioPlayer?.prepareToPlay() audioPlayer?.prepareToPlay()
audioPlayer?.numberOfLoops = -1 // Loop indefinitely audioPlayer?.numberOfLoops = -1 // Loop indefinitely
} catch { } catch {
Logger.view.error("Error loading sound file: \(error)") print("Error loading sound file: \(error)")
} }
} }
} }
@@ -186,9 +180,9 @@ extension RecipeTimer {
func requestNotificationPermissions() { func requestNotificationPermissions() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted { if granted {
Logger.view.debug("Notification permission granted.") print("Notification permission granted.")
} else if let error = error { } else if let error = error {
Logger.view.error("Notification permission denied because: \(error.localizedDescription).") print("Notification permission denied because: \(error.localizedDescription).")
} }
} }
} }
@@ -205,7 +199,7 @@ extension RecipeTimer {
UNUserNotificationCenter.current().add(request) { error in UNUserNotificationCenter.current().add(request) { error in
if let error = error { if let error = error {
Logger.view.error("Error scheduling notification: \(error)") print("Error scheduling notification: \(error)")
} }
} }
} }

View File

@@ -48,3 +48,4 @@ struct CollapsibleView<C: View, T: View>: View {
} }
} }
} }

View File

@@ -1,28 +0,0 @@
//
// LiquidGlassModifiers.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 14.02.26.
//
import SwiftUI
struct TabBarMinimizeModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content.tabBarMinimizeBehavior(.onScrollDown)
} else {
content
}
}
}
struct BackgroundExtensionModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content.backgroundExtensionEffect()
} else {
content
}
}
}

View File

@@ -0,0 +1,38 @@
//
// ListVStack.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import SwiftUI
struct ListVStack<Element, HeaderContent: View, RowContent: View>: View {
@Binding var items: [Element]
let header: () -> HeaderContent
let rows: (Int, Binding<Element>) -> RowContent
init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding<Element>) -> RowContent) {
self._items = items
self.header = header
self.rows = rows
}
var body: some View {
VStack(alignment: .leading) {
header()
.padding(.horizontal, 30)
VStack(alignment: .leading, spacing: 0) {
ForEach(items.indices, id: \.self) { index in
rows(index, $items[index])
.padding(10)
}
}
.padding(4)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal)
}
}
}

View File

@@ -6,12 +6,15 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
struct SettingsView: View { struct SettingsView: View {
var body: some View {
Text("Settings")
}
}
/*struct SettingsView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@ObservedObject var userSettings = UserSettings.shared @ObservedObject var userSettings = UserSettings.shared
@ObservedObject var viewModel = ViewModel() @ObservedObject var viewModel = ViewModel()
@@ -136,7 +139,7 @@ struct SettingsView: View {
Section { Section {
Button("Log out") { Button("Log out") {
Logger.view.debug("Log out.") print("Log out.")
viewModel.alertType = .LOG_OUT viewModel.alertType = .LOG_OUT
viewModel.showAlert = true viewModel.showAlert = true
@@ -144,7 +147,7 @@ struct SettingsView: View {
.tint(.red) .tint(.red)
Button("Delete local data") { Button("Delete local data") {
Logger.view.debug("Clear cache.") print("Clear cache.")
viewModel.alertType = .DELETE_CACHE viewModel.alertType = .DELETE_CACHE
viewModel.showAlert = true viewModel.showAlert = true
} }
@@ -249,3 +252,4 @@ extension SettingsView {
*/

View File

@@ -6,59 +6,159 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import SwiftData
@Model class GroceryItem {
var name: String
var isChecked: Bool
init(name: String, isChecked: Bool) {
self.name = name
self.isChecked = isChecked
}
}
@Model class RecipeGroceries: Identifiable {
var id: String
var name: String
@Relationship(deleteRule: .cascade) var items: [GroceryItem]
var multiplier: Double
init(id: String, name: String, items: [GroceryItem], multiplier: Double) {
self.id = id
self.name = name
self.items = items
self.multiplier = multiplier
}
init(id: String, name: String) {
self.id = id
self.name = name
self.items = []
self.multiplier = 1
}
}
struct GroceryListTabView: View { struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList @Environment(\.modelContext) var modelContext
@Query var groceryList: [RecipeGroceries] = []
@State var newGroceries: String = ""
@FocusState private var isFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
if groceryList.groceryDict.isEmpty { List {
EmptyGroceryListView() HStack(alignment: .top) {
} else { TextEditor(text: $newGroceries)
List { .padding(4)
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in .overlay(RoundedRectangle(cornerRadius: 8)
Section { .stroke(Color.secondary).opacity(0.5))
ForEach(groceryList.groceryDict[key]!.items) { item in .focused($isFocused)
GroceryListItemView(item: item, toggleAction: { Button {
groceryList.toggleItemChecked(item) if !newGroceries.isEmpty {
groceryList.objectWillChange.send() let items = newGroceries
}, deleteAction: { .split(separator: "\n")
groceryList.deleteItem(item.name, fromRecipe: key) .compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
withAnimation { .filter { !$0.isEmpty }
groceryList.objectWillChange.send() Task {
} await addGroceryItems(items, toCategory: "Other", named: String(localized: "Other"))
})
} }
} header: { }
HStack { newGroceries = ""
Text(groceryList.groceryDict[key]!.name)
} label: {
Text("Add")
}
.disabled(newGroceries.isEmpty)
.buttonStyle(.borderedProminent)
}
ForEach(groceryList, id: \.name) { category in
Section {
ForEach(category.items, id: \.self) { item in
GroceryListItemView(item: item)
}
} header: {
HStack {
Text(category.name)
.foregroundStyle(Color.nextcloudBlue)
Spacer()
Button {
modelContext.delete(category)
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue) .foregroundStyle(Color.nextcloudBlue)
Spacer()
Button {
groceryList.deleteGroceryRecipe(key)
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue)
}
} }
} }
} }
} }
.listStyle(.plain) if groceryList.isEmpty {
.navigationTitle("Grocery List") Text("You're all set for cooking 🍓")
.toolbar { .font(.headline)
Button { Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
groceryList.deleteAll() .foregroundStyle(.secondary)
} label: { Text("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
Text("Delete") .foregroundStyle(.secondary)
.foregroundStyle(Color.nextcloudBlue) Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
} .foregroundStyle(.secondary)
} }
} }
.listStyle(.plain)
.navigationTitle("Grocery List")
.toolbar {
Button {
do {
try modelContext.delete(model: RecipeGroceries.self)
} catch {
print("Failed to delete all GroceryCategory models.")
}
} label: {
Text("Delete")
.foregroundStyle(Color.nextcloudBlue)
}
}
}
}
private func addGroceryItems(_ itemNames: [String], toCategory categoryId: String, named name: String) async {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
var targetCategory: RecipeGroceries?
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
targetCategory = existingCategory
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
targetCategory = newCategory
}
guard let category = targetCategory else { return }
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
category.items.append(newItem)
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
private func deleteGroceryItems(at offsets: IndexSet, in category: RecipeGroceries) {
for index in offsets {
let itemToDelete = category.items[index]
modelContext.delete(itemToDelete)
} }
} }
} }
@@ -66,9 +166,8 @@ struct GroceryListTabView: View {
fileprivate struct GroceryListItemView: View { fileprivate struct GroceryListItemView: View {
let item: GroceryRecipeItem @Environment(\.modelContext) var modelContext
let toggleAction: () -> Void @Bindable var item: GroceryItem
let deleteAction: () -> Void
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
@@ -84,155 +183,13 @@ fileprivate struct GroceryListItemView: View {
} }
.padding(5) .padding(5)
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary) .foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture(perform: toggleAction) .onTapGesture(perform: { item.isChecked.toggle() })
.animation(.easeInOut, value: item.isChecked) .animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(action: deleteAction) { Button(action: { modelContext.delete(item) }) {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
} }
} }
fileprivate struct EmptyGroceryListView: View {
var body: some View {
List {
Text("You're all set for cooking 🍓")
.font(.headline)
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
}
.navigationTitle("Grocery List")
}
}
// Grocery List Logic
class GroceryRecipe: Identifiable, Codable {
let name: String
var items: [GroceryRecipeItem]
init(name: String, items: [GroceryRecipeItem]) {
self.name = name
self.items = items
}
init(name: String, item: GroceryRecipeItem) {
self.name = name
self.items = [item]
}
}
class GroceryRecipeItem: Identifiable, Codable {
let name: String
var isChecked: Bool
init(_ name: String, isChecked: Bool = false) {
self.name = name
self.isChecked = isChecked
}
}
@MainActor class GroceryList: ObservableObject {
let dataStore: DataStore = DataStore()
@Published var groceryDict: [String: GroceryRecipe] = [:]
@Published var sortBySimilarity: Bool = false
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
Logger.view.debug("Adding item of recipe \(String(describing: recipeName))")
DispatchQueue.main.async {
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
self.groceryDict[recipeId] = newRecipe
}
if saveGroceryDict {
self.save()
self.objectWillChange.send()
}
}
}
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
for item in items {
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
save()
objectWillChange.send()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
Logger.view.debug("Deleting item \(itemName)")
guard let recipe = groceryDict[recipeId] else { return }
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
groceryDict[recipeId]?.items.remove(at: itemIndex)
if groceryDict[recipeId]!.items.isEmpty {
groceryDict.removeValue(forKey: recipeId)
}
save()
objectWillChange.send()
}
func deleteGroceryRecipe(_ recipeId: String) {
Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
objectWillChange.send()
}
func deleteAll() {
Logger.view.debug("Deleting all grocery items")
groceryDict = [:]
save()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
Logger.view.debug("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
}
func containsItem(at recipeId: String, item: String) -> Bool {
guard let recipe = groceryDict[recipeId] else { return false }
if recipe.items.contains(where: { $0.name == item }) {
return true
}
return false
}
func containsRecipe(_ recipeId: String) -> Bool {
return groceryDict[recipeId] != nil
}
func save() {
Task {
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
}
}
func load() async {
do {
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
fromPath: "grocery_list.data"
) else { return }
self.groceryDict = groceryDict
} catch {
Logger.view.error("Unable to load grocery list")
}
}
}

View File

@@ -6,90 +6,87 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import SwiftData
struct RecipeTabView: View { struct RecipeTabView: View {
@EnvironmentObject var appState: AppState //@State var cookbookState: CookbookState = CookbookState()
@EnvironmentObject var groceryList: GroceryList @Environment(\.modelContext) var modelContext
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @Query var recipes: [Recipe]
@State var categories: [(String, Int)] = []
@State private var selectedRecipe: Recipe?
@State private var selectedCategory: String? = "*"
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
List(selection: $viewModel.selectedCategory) { List(selection: $selectedCategory) {
// Categories CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*")
ForEach(appState.categories) { category in .tag("*") // Tag nil to select all recipes
NavigationLink(value: category) {
HStack(alignment: .center) { Section("Categories") {
if viewModel.selectedCategory != nil && ForEach(categories, id: \.0.self) { category in
category.name == viewModel.selectedCategory!.name { CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0)
Image(systemName: "book") .tag(category.0)
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
} }
} }
} }
.navigationTitle("Cookbooks") .navigationTitle("Categories")
.toolbar { } content: {
RecipeTabViewToolBar() RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe)
}
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
SettingsView()
.environmentObject(appState)
}
.navigationDestination(isPresented: $viewModel.presentEditView) {
RecipeView(viewModel: RecipeView.ViewModel())
.environmentObject(appState)
.environmentObject(groceryList)
}
} detail: { } detail: {
NavigationStack { // Use a conditional view based on selection
if let category = viewModel.selectedCategory { if let selectedRecipe {
RecipeListView( //RecipeDetailView(recipe: recipe) // Create a dedicated detail view
categoryName: category.name, RecipeView(recipe: selectedRecipe, viewModel: RecipeView.ViewModel(recipe: selectedRecipe))
showEditView: $viewModel.presentEditView } else {
) ContentUnavailableView("Select a Recipe", systemImage: "fork.knife.circle")
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
}
} }
} }
.tint(.nextcloudBlue)
.task { .task {
let connection = await appState.checkServerConnection() initCategories()
DispatchQueue.main.async { return
viewModel.serverConnection = connection do {
try modelContext.delete(model: Recipe.self)
} catch {
print("Failed to delete recipes and categories.")
}
guard let categories = await CookbookApiV1.getCategories(auth: UserSettings.shared.authString).0 else { return }
for category in categories {
guard let recipeStubs = await CookbookApiV1.getCategory(auth: UserSettings.shared.authString, named: category.name).0 else { return }
for recipeStub in recipeStubs {
guard let recipe = await CookbookApiV1.getRecipe(auth: UserSettings.shared.authString, id: recipeStub.id).0 else { return }
modelContext.insert(recipe)
}
}
}/*
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: {
//cookbookState.showSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
}
}*/
}
func initCategories() {
// Load Categories
var categoryDict: [String: Int] = [:]
for recipe in recipes {
// Ensure "Uncategorized" is a valid category if used
if !recipe.category.isEmpty {
categoryDict[recipe.category, default: 0] += 1
} else {
categoryDict["Other", default: 0] += 1
} }
} }
.refreshable { categories = categoryDict.map {
let connection = await appState.checkServerConnection() ($0.key, $0.value)
DispatchQueue.main.async { }.sorted { $0.0 < $1.0 }
viewModel.serverConnection = connection
}
await appState.getCategories()
}
} }
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@@ -99,13 +96,40 @@ struct RecipeTabView: View {
@Published var presentLoadingIndicator: Bool = false @Published var presentLoadingIndicator: Bool = false
@Published var presentConnectionPopover: Bool = false @Published var presentConnectionPopover: Bool = false
@Published var serverConnection: Bool = false @Published var serverConnection: Bool = false
@Published var selectedCategory: Category? = nil
} }
} }
fileprivate struct CategoryListItem: View {
var category: String
var count: Int
var isSelected: Bool
var body: some View {
HStack(alignment: .center) {
if isSelected {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
Text(category)
.font(.system(size: 20, weight: .medium, design: .default))
Spacer()
Text("\(count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
/*
fileprivate struct RecipeTabViewToolBar: ToolbarContent { fileprivate struct RecipeTabViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @EnvironmentObject var viewModel: RecipeTabView.ViewModel
@@ -144,7 +168,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Server connection indicator // Server connection indicator
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
Logger.view.debug("Check server connection") print("Check server connection")
viewModel.presentConnectionPopover = true viewModel.presentConnectionPopover = true
} label: { } label: {
if viewModel.presentLoadingIndicator { if viewModel.presentLoadingIndicator {
@@ -171,7 +195,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Create new recipes // Create new recipes
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
Logger.view.debug("Add new recipe") print("Add new recipe")
viewModel.presentEditView = true viewModel.presentEditView = true
} label: { } label: {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
@@ -180,3 +204,5 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
} }
} }
*/

View File

@@ -7,7 +7,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
/*
struct SearchTabView: View { struct SearchTabView: View {
@EnvironmentObject var viewModel: SearchTabView.ViewModel @EnvironmentObject var viewModel: SearchTabView.ViewModel
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@@ -30,7 +30,7 @@ struct SearchTabView: View {
.listRowSeparatorTint(.clear) .listRowSeparatorTint(.clear)
} }
.listStyle(.plain) .listStyle(.plain)
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
} }
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords") .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
@@ -48,7 +48,7 @@ struct SearchTabView: View {
} }
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var allRecipes: [Recipe] = [] @Published var allRecipes: [CookbookApiRecipeV1] = []
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var searchMode: SearchMode = .name @Published var searchMode: SearchMode = .name
@@ -58,7 +58,7 @@ struct SearchTabView: View {
case name = "Name & Keywords", ingredient = "Ingredients" case name = "Name & Keywords", ingredient = "Ingredients"
} }
func recipesFiltered() -> [Recipe] { func recipesFiltered() -> [CookbookApiRecipeV1] {
if searchMode == .name { if searchMode == .name {
guard searchText != "" else { return allRecipes } guard searchText != "" else { return allRecipes }
return allRecipes.filter { recipe in return allRecipes.filter { recipe in
@@ -72,3 +72,4 @@ struct SearchTabView: View {
} }
} }
} }
*/

View File

@@ -0,0 +1,234 @@
//
// SettingsTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import Foundation
import SwiftUI
struct SettingsTabView: View {
@ObservedObject var userSettings = UserSettings.shared
@State private var avatarImage: UIImage?
@State private var userData: UserData?
@State private var showAlert: Bool = false
@State private var alertType: SettingsAlert = .NONE
@State private var presentLoginSheet: Bool = false
enum SettingsAlert {
case LOG_OUT,
DELETE_CACHE,
NONE
func getTitle() -> String {
switch self {
case .LOG_OUT: return "Log out"
case .DELETE_CACHE: return "Delete local data"
default: return "Please confirm your action."
}
}
func getMessage() -> String {
switch self {
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
default: return ""
}
}
}
var body: some View {
Form {
Section {
if userSettings.authString.isEmpty {
HStack(alignment: .center) {
if let avatarImage = avatarImage {
Image(uiImage: avatarImage)
.resizable()
.clipShape(Circle())
.frame(width: 100, height: 100)
}
if let userData = userData {
VStack(alignment: .leading) {
Text(userData.userDisplayName)
.font(.title)
.padding(.leading)
Text("Username: \(userData.userId)")
.font(.subheadline)
.padding(.leading)
// TODO: Add actions
}
}
Spacer()
}
Button("Log out") {
print("Log out.")
alertType = .LOG_OUT
showAlert = true
}
.tint(.red)
} else {
Button("Log in") {
print("Log in.")
presentLoginSheet.toggle()
}
}
} header: {
Text("Nextcloud")
} footer: {
Text("Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed.")
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.keepScreenAwake) {
Text("Keep screen awake when viewing recipes")
}
}
Section {
HStack {
Text("Decimal number format")
Spacer()
Picker("", selection: $userSettings.decimalNumberSeparator) {
Text("Point (e.g. 1.42)").tag(".")
Text("Comma (e.g. 1,42)").tag(",")
}
.pickerStyle(.menu)
}
} footer: {
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
}
Toggle(isOn: $userSettings.storeImages) {
Text("Store recipe images locally")
}
Toggle(isOn: $userSettings.storeThumb) {
Text("Store recipe thumbnails locally")
}
} header: {
Text("Downloads")
} footer: {
Text("Configure what is stored on your device.")
}
Section {
Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
Text(lang.descriptor()).tag(lang.rawValue)
}
}
} footer: {
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
}
Section {
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
} header: {
Text("About")
} footer: {
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
}
Section {
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
} header: {
Text("Support")
} footer: {
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
}
Section {
Button("Delete local data") {
print("Clear cache.")
alertType = .DELETE_CACHE
showAlert = true
}
.tint(.red)
} header: {
Text("Other")
} footer: {
Text("Deleting local data will not affect the recipe data stored on your server.")
}
Section(header: Text("Acknowledgements")) {
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
Link("SwiftSoup", destination: url)
.font(.headline)
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
}
}
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
Link("TPPDF", destination: url)
.font(.headline)
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
}
}
}
}
.navigationTitle("Settings")
.alert(alertType.getTitle(), isPresented: $showAlert) {
Button("Cancel", role: .cancel) { }
if alertType == .DELETE_CACHE {
Button("Delete", role: .destructive) { deleteCachedData() }
}
} message: {
Text(alertType.getMessage())
}
.task {
await getUserData()
}
.sheet(isPresented: $presentLoginSheet, onDismiss: {}) {
V2LoginView()
}
}
func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar()
let (userData, _) = await NextcloudApi.getHoverCard()
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
}
func deleteCachedData() {
print("TODO: Delete cached data\n")
}
}

View File

@@ -34,7 +34,7 @@ You can download the app from the AppStore:
- [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number. - [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code. Update: I will continue to work on this update in January 2024. - [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code.
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!): - [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):