Compare commits
3 Commits
main
...
c8ddb098d1
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ddb098d1 | |||
| 7c824b492e | |||
| 527acd2967 |
114
CLAUDE.md
Normal file
114
CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
@@ -20,19 +20,18 @@
|
||||
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.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 */; };
|
||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.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 */; };
|
||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.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 */; };
|
||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
||||
@@ -40,6 +39,7 @@
|
||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
||||
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 */; };
|
||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||
@@ -103,18 +103,18 @@
|
||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.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>"; };
|
||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
|
||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -122,6 +122,7 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -155,7 +156,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
|
||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -261,7 +261,6 @@
|
||||
A70171B72AB2445700064C43 /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
@@ -312,7 +311,6 @@
|
||||
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
|
||||
);
|
||||
path = RecipeImport;
|
||||
sourceTree = "<group>";
|
||||
@@ -387,6 +385,8 @@
|
||||
children = (
|
||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||
A97506112B920D8100E86029 /* RecipeViewSections */,
|
||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||
@@ -401,6 +401,7 @@
|
||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
||||
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */,
|
||||
);
|
||||
path = ReusableViews;
|
||||
sourceTree = "<group>";
|
||||
@@ -438,7 +439,6 @@
|
||||
);
|
||||
name = "Nextcloud Cookbook iOS Client";
|
||||
packageProductDependencies = (
|
||||
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
|
||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
|
||||
);
|
||||
productName = "Nextcloud Cookbook iOS Client";
|
||||
@@ -517,7 +517,6 @@
|
||||
);
|
||||
mainGroup = A70171752AA8E71900064C43;
|
||||
packageReferences = (
|
||||
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
||||
);
|
||||
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
||||
@@ -572,7 +571,7 @@
|
||||
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
|
||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
||||
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
||||
@@ -586,6 +585,7 @@
|
||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
||||
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */,
|
||||
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
||||
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
|
||||
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
|
||||
@@ -602,6 +602,8 @@
|
||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
|
||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||
@@ -609,7 +611,6 @@
|
||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
||||
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
|
||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
||||
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
|
||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
||||
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
||||
@@ -793,7 +794,7 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -837,7 +838,7 @@
|
||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
@@ -863,7 +864,7 @@
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||
@@ -887,7 +888,7 @@
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||
@@ -910,7 +911,7 @@
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||
@@ -933,7 +934,7 @@
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||
@@ -989,14 +990,6 @@
|
||||
/* End XCConfigurationList 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" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/techprimate/TPPDF.git";
|
||||
@@ -1008,11 +1001,6 @@
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A74D33BD2AF82AAE00D06555 /* SwiftSoup */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||
productName = SwiftSoup;
|
||||
};
|
||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
{
|
||||
"originHash" : "314ca0b5cf5f134470eb4e9e12133500ae78d8b9a08f490e0065f2b3ceb4a25a",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swiftsoup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state" : {
|
||||
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
|
||||
"version" : "2.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tppdf",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -19,5 +11,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@@ -15,16 +16,20 @@ import UIKit
|
||||
@Published var recipes: [String: [Recipe]] = [:]
|
||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
||||
@Published var timers: [String: RecipeTimer] = [:]
|
||||
@Published var categoryImages: [String: UIImage] = [:]
|
||||
@Published var recentRecipes: [Recipe] = []
|
||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||
var lastUpdates: [String: Date] = [:]
|
||||
var allKeywords: [RecipeKeyword] = []
|
||||
|
||||
private let dataStore: DataStore
|
||||
private let api: CookbookApiProtocol
|
||||
|
||||
init() {
|
||||
print("Created MainViewModel")
|
||||
init(api: CookbookApiProtocol? = nil) {
|
||||
Logger.network.debug("Created AppState")
|
||||
self.dataStore = DataStore()
|
||||
self.api = api ?? CookbookApiFactory.makeClient()
|
||||
|
||||
if UserSettings.shared.authString == "" {
|
||||
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||
@@ -38,54 +43,33 @@ import UIKit
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
Asynchronously loads and updates the list of categories.
|
||||
// MARK: - 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.
|
||||
|
||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
||||
*/
|
||||
func getCategories() async {
|
||||
let (categories, _) = await cookbookApi.getCategories(
|
||||
auth: UserSettings.shared.authString
|
||||
)
|
||||
if let categories = categories {
|
||||
print("Successfully loaded categories")
|
||||
do {
|
||||
let categories = try await api.getCategories()
|
||||
Logger.data.debug("Successfully loaded categories")
|
||||
self.categories = categories
|
||||
await saveLocal(self.categories, path: "categories.data")
|
||||
} else {
|
||||
// If there's no server connection, try loading categories from local storage
|
||||
print("Loading categories from store ...")
|
||||
} catch {
|
||||
Logger.data.debug("Loading categories from store ...")
|
||||
if let categories: [Category] = await loadLocal(path: "categories.data") {
|
||||
self.categories = categories
|
||||
print("Success!")
|
||||
Logger.data.debug("Loaded categories from local store")
|
||||
} else {
|
||||
print("Failure!")
|
||||
Logger.data.error("Failed to load categories from local store")
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the lastUpdates with distantPast dates, so that each recipeDetail is updated on launch for all categories
|
||||
for category in self.categories {
|
||||
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 {
|
||||
print("getCategory(\(name), fetchMode: \(fetchMode))")
|
||||
Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))")
|
||||
func getLocal() async -> Bool {
|
||||
let categoryString = name == "*" ? "_" : name
|
||||
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
|
||||
self.recipes[name] = recipes
|
||||
return true
|
||||
@@ -94,22 +78,19 @@ import UIKit
|
||||
}
|
||||
|
||||
func getServer(store: Bool = false) async -> Bool {
|
||||
let (recipes, _) = await cookbookApi.getCategory(
|
||||
auth: UserSettings.shared.authString,
|
||||
named: categoryString
|
||||
)
|
||||
if let recipes = recipes {
|
||||
let categoryString = name == "*" ? "_" : name
|
||||
do {
|
||||
let recipes = try await api.getCategory(named: categoryString)
|
||||
self.recipes[name] = recipes
|
||||
if store {
|
||||
await saveLocal(recipes, path: "category_\(categoryString).data")
|
||||
}
|
||||
//userSettings.lastUpdate = Date()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let categoryString = name == "*" ? "_" : name
|
||||
switch fetchMode {
|
||||
case .preferLocal:
|
||||
if await getLocal() { return }
|
||||
@@ -124,6 +105,8 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recipe details
|
||||
|
||||
func updateAllRecipeDetails() async {
|
||||
for category in self.categories {
|
||||
await updateRecipeDetails(in: category.name)
|
||||
@@ -137,10 +120,10 @@ import UIKit
|
||||
for recipe in recipes {
|
||||
if let dateModified = recipe.dateModified {
|
||||
if needsUpdate(category: category, lastModified: dateModified) {
|
||||
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)")
|
||||
Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))")
|
||||
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||
} else {
|
||||
print("\(recipe.name) is up to date.")
|
||||
Logger.data.debug("\(recipe.name) is up to date.")
|
||||
}
|
||||
} else {
|
||||
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||
@@ -148,25 +131,11 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Asynchronously retrieves all recipes either from the server or the locally cached data.
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
Example usage:
|
||||
```swift
|
||||
let recipes = await mainViewModel.getRecipes()
|
||||
*/
|
||||
func getRecipes() async -> [Recipe] {
|
||||
let (recipes, error) = await cookbookApi.getRecipes(
|
||||
auth: UserSettings.shared.authString
|
||||
)
|
||||
if let recipes = recipes {
|
||||
return recipes
|
||||
} else if let error = error {
|
||||
print(error)
|
||||
do {
|
||||
return try await api.getRecipes()
|
||||
} catch {
|
||||
Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)")
|
||||
}
|
||||
var allRecipes: [Recipe] = []
|
||||
for category in categories {
|
||||
@@ -174,25 +143,9 @@ import UIKit
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
- 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 -> RecipeDetail? {
|
||||
func getLocal() async -> RecipeDetail? {
|
||||
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
|
||||
@@ -200,20 +153,17 @@ import UIKit
|
||||
}
|
||||
|
||||
func getServer() async -> RecipeDetail? {
|
||||
let (recipe, error) = await cookbookApi.getRecipe(
|
||||
auth: UserSettings.shared.authString,
|
||||
id: id
|
||||
)
|
||||
if let recipe = recipe {
|
||||
do {
|
||||
let recipe = try await api.getRecipe(id: id)
|
||||
if save {
|
||||
self.recipeDetails[id] = recipe
|
||||
await self.saveLocal(recipe, path: "recipe\(id).data")
|
||||
}
|
||||
return recipe
|
||||
} else if let error = error {
|
||||
print(error)
|
||||
} catch {
|
||||
Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
switch fetchMode {
|
||||
@@ -231,18 +181,6 @@ import UIKit
|
||||
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 {
|
||||
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
|
||||
await saveLocal(recipeDetail, path: "recipe\(id).data")
|
||||
@@ -263,47 +201,23 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 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 {
|
||||
if (dataStore.recipeDetailExists(recipeId: recipeId)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
return dataStore.recipeDetailExists(recipeId: recipeId)
|
||||
}
|
||||
|
||||
/**
|
||||
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 getLocal() async -> UIImage? {
|
||||
return await imageFromStore(id: id, size: size)
|
||||
}
|
||||
|
||||
func getServer() async -> UIImage? {
|
||||
let (image, _) = await cookbookApi.getImage(
|
||||
auth: UserSettings.shared.authString,
|
||||
id: id,
|
||||
size: size
|
||||
)
|
||||
if let image = image { return image }
|
||||
return nil
|
||||
do {
|
||||
return try await api.getImage(id: id, size: size)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch fetchMode {
|
||||
@@ -356,27 +270,19 @@ import UIKit
|
||||
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 getLocal() async -> [RecipeKeyword]? {
|
||||
return await loadLocal(path: "keywords.data")
|
||||
}
|
||||
|
||||
func getServer() async -> [RecipeKeyword]? {
|
||||
let (tags, _) = await cookbookApi.getTags(
|
||||
auth: UserSettings.shared.authString
|
||||
)
|
||||
return tags
|
||||
do {
|
||||
return try await api.getTags()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
switch fetchMode {
|
||||
@@ -400,6 +306,49 @@ import UIKit
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Category images
|
||||
|
||||
func getCategoryImage(for categoryName: String) async {
|
||||
guard categoryImages[categoryName] == nil else { return }
|
||||
// Ensure recipes for this category are loaded
|
||||
if self.recipes[categoryName] == nil || self.recipes[categoryName]!.isEmpty {
|
||||
await getCategory(named: categoryName, fetchMode: .preferLocal)
|
||||
}
|
||||
guard let recipes = self.recipes[categoryName], !recipes.isEmpty else { return }
|
||||
for recipe in recipes {
|
||||
if let image = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) {
|
||||
self.categoryImages[categoryName] = image
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent recipes
|
||||
|
||||
func addToRecentRecipes(_ recipe: Recipe) {
|
||||
recentRecipes.removeAll { $0.recipe_id == recipe.recipe_id }
|
||||
recentRecipes.insert(recipe, at: 0)
|
||||
if recentRecipes.count > 10 {
|
||||
recentRecipes = Array(recentRecipes.prefix(10))
|
||||
}
|
||||
Task {
|
||||
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
||||
}
|
||||
}
|
||||
|
||||
func loadRecentRecipes() async {
|
||||
if let loaded: [Recipe] = await loadLocal(path: "recent_recipes.data") {
|
||||
self.recentRecipes = loaded
|
||||
}
|
||||
}
|
||||
|
||||
func clearRecentRecipes() {
|
||||
recentRecipes = []
|
||||
dataStore.delete(path: "recent_recipes.data")
|
||||
}
|
||||
|
||||
// MARK: - Data management
|
||||
|
||||
func deleteAllData() {
|
||||
if dataStore.clearAll() {
|
||||
self.categories = []
|
||||
@@ -410,30 +359,13 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
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? {
|
||||
let (error) = await cookbookApi.deleteRecipe(
|
||||
auth: UserSettings.shared.authString,
|
||||
id: id
|
||||
)
|
||||
|
||||
if let error = error {
|
||||
do {
|
||||
try await api.deleteRecipe(id: id)
|
||||
} catch {
|
||||
return .REQUEST_DROPPED
|
||||
}
|
||||
|
||||
let path = "recipe\(id).data"
|
||||
dataStore.delete(path: path)
|
||||
if recipes[categoryName] != nil {
|
||||
@@ -445,86 +377,50 @@ import UIKit
|
||||
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 {
|
||||
let (categories, _) = await cookbookApi.getCategories(
|
||||
auth: UserSettings.shared.authString
|
||||
)
|
||||
if let categories = categories {
|
||||
do {
|
||||
let categories = try await api.getCategories()
|
||||
self.categories = categories
|
||||
await saveLocal(categories, path: "categories.data")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
Asynchronously uploads a recipe to the server.
|
||||
|
||||
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`.
|
||||
|
||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
||||
|
||||
- Parameters:
|
||||
- recipeDetail: The detailed information of the recipe to upload.
|
||||
- createNew: If true, creates a new recipe on the server; otherwise, updates an existing one.
|
||||
|
||||
Example usage:
|
||||
```swift
|
||||
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
|
||||
*/
|
||||
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? {
|
||||
var error: NetworkError? = nil
|
||||
if createNew {
|
||||
error = await cookbookApi.createRecipe(
|
||||
auth: UserSettings.shared.authString,
|
||||
recipe: recipeDetail
|
||||
)
|
||||
} else {
|
||||
error = await cookbookApi.updateRecipe(
|
||||
auth: UserSettings.shared.authString,
|
||||
recipe: recipeDetail
|
||||
)
|
||||
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) {
|
||||
do {
|
||||
if createNew {
|
||||
let id = try await api.createRecipe(recipeDetail)
|
||||
return (id, nil)
|
||||
} else {
|
||||
let id = try await api.updateRecipe(recipeDetail)
|
||||
return (id, nil)
|
||||
}
|
||||
} catch {
|
||||
return (nil, .REQUEST_DROPPED)
|
||||
}
|
||||
if error != nil {
|
||||
return .REQUEST_DROPPED
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func importRecipe(url: String) async -> (RecipeDetail?, 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 {
|
||||
do {
|
||||
let recipeDetail = try await api.importRecipe(url: url)
|
||||
return (recipeDetail, nil)
|
||||
} catch {
|
||||
return (nil, .REQUEST_DROPPED)
|
||||
}
|
||||
return (recipeDetail, nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Local storage helpers
|
||||
|
||||
extension AppState {
|
||||
func loadLocal<T: Codable>(path: String) async -> T? {
|
||||
do {
|
||||
return try await dataStore.load(fromPath: path)
|
||||
} catch (let error) {
|
||||
print(error)
|
||||
} catch {
|
||||
Logger.data.debug("Failed to load local data: \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -542,7 +438,7 @@ extension AppState {
|
||||
return image
|
||||
}
|
||||
} catch {
|
||||
print("Could not find image in local storage.")
|
||||
Logger.data.debug("Could not find image in local storage.")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
@@ -584,24 +480,20 @@ extension AppState {
|
||||
}
|
||||
|
||||
private func needsUpdate(category: String, lastModified: String) -> Bool {
|
||||
print("=======================")
|
||||
print("original date string: \(lastModified)")
|
||||
// Create a DateFormatter
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
|
||||
// Convert the string to a Date object
|
||||
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
|
||||
if date < lastUpdate {
|
||||
print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
|
||||
Logger.data.debug("No update needed for \(category)")
|
||||
return false
|
||||
} else {
|
||||
print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
|
||||
Logger.data.debug("Update needed for \(category)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
print("String is not a date. Update needed.")
|
||||
Logger.data.debug("Date parse failed, update needed for \(category)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ struct Category: Codable {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
class DataStore {
|
||||
@@ -52,7 +53,7 @@ class DataStore {
|
||||
do {
|
||||
_ = try await task.value
|
||||
} catch {
|
||||
print("Could not save data (path: \(path)")
|
||||
Logger.data.error("Could not save data (path: \(path))")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,24 +71,18 @@ class DataStore {
|
||||
}
|
||||
|
||||
func clearAll() -> Bool {
|
||||
print("Attempting to delete all data ...")
|
||||
Logger.data.debug("Attempting to delete all data ...")
|
||||
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
|
||||
print("Folder path: ", folderPath)
|
||||
do {
|
||||
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
|
||||
for filePath in filePaths {
|
||||
print("File path: ", filePath)
|
||||
try fileManager.removeItem(atPath: folderPath + filePath)
|
||||
}
|
||||
} catch {
|
||||
print("Could not delete documents folder contents: \(error)")
|
||||
Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
print("Done.")
|
||||
Logger.data.debug("All data deleted successfully.")
|
||||
return true
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
class ObservableRecipeDetail: ObservableObject {
|
||||
@@ -180,7 +181,7 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
}
|
||||
return foundMatches
|
||||
} catch {
|
||||
print("Regex error: \(error.localizedDescription)")
|
||||
Logger.data.error("Regex error: \(error.localizedDescription)")
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ struct Recipe: Codable {
|
||||
|
||||
|
||||
extension Recipe: Identifiable, Hashable {
|
||||
var id: String { name }
|
||||
var id: Int { recipe_id }
|
||||
}
|
||||
|
||||
|
||||
@@ -93,29 +93,67 @@ struct RecipeDetail: Codable {
|
||||
|
||||
// Custom decoder to handle value type ambiguity
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
|
||||
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
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)
|
||||
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
||||
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
|
||||
// Server import returns "image"; show/index responses and local storage use "imageUrl"
|
||||
imageUrl = (try? container.decodeIfPresent(String.self, forKey: .image))
|
||||
?? (try? container.decodeIfPresent(String.self, forKey: .imageUrl))
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
|
||||
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
|
||||
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
||||
description = try container.decode(String.self, forKey: .description)
|
||||
url = try container.decode(String.self, forKey: .url)
|
||||
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)
|
||||
description = (try? container.decode(String.self, forKey: .description)) ?? ""
|
||||
url = try? container.decode(String.self, forKey: .url)
|
||||
recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? ""
|
||||
|
||||
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
|
||||
// recipeYield: try Int first, then parse leading digits from String
|
||||
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 {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(keywords, forKey: .keywords)
|
||||
try container.encodeIfPresent(dateCreated, forKey: .dateCreated)
|
||||
try container.encodeIfPresent(dateModified, forKey: .dateModified)
|
||||
// Encode under "image" — the key the server expects for create/update
|
||||
try container.encodeIfPresent(imageUrl, forKey: .image)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encodeIfPresent(prepTime, forKey: .prepTime)
|
||||
try container.encodeIfPresent(cookTime, forKey: .cookTime)
|
||||
try container.encodeIfPresent(totalTime, forKey: .totalTime)
|
||||
try container.encode(description, forKey: .description)
|
||||
try container.encodeIfPresent(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
extension JSONDecoder {
|
||||
static func safeDecode<T: Decodable>(_ data: Data) -> T? {
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
return try decoder.decode(T.self, from: data)
|
||||
} catch (let error) {
|
||||
print(error)
|
||||
} catch {
|
||||
Logger.data.error("JSONDecoder - safeDecode(): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -24,7 +25,7 @@ extension JSONEncoder {
|
||||
do {
|
||||
return try JSONEncoder().encode(object)
|
||||
} catch {
|
||||
print("JSONDecoder - safeEncode(): Could not encode object \(T.self)")
|
||||
Logger.data.error("JSONEncoder - safeEncode(): Could not encode \(String(describing: T.self))")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,4 +16,7 @@ extension Logger {
|
||||
|
||||
/// Network related logging
|
||||
static let network = Logger(subsystem: subsystem, category: "network")
|
||||
|
||||
/// Data/persistence related logging
|
||||
static let data = Logger(subsystem: subsystem, category: "data")
|
||||
}
|
||||
|
||||
@@ -284,6 +284,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld recipes" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "%lld Rezepte"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld Serving(s)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -401,7 +411,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "Ein einfach zu verwendender PDF-Ersteller für Swift. Wird zum Erzeugen von Rezept-PDF-Dokumenten verwendet."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -467,7 +477,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "Aktion verzögert"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -578,7 +588,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "Eine HTML-Parsing- und Web-Scraping-Bibliothek für Swift. Wird zum Importieren von schema.org-Rezepten von Webseiten verwendet."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -663,6 +673,7 @@
|
||||
}
|
||||
},
|
||||
"Bad URL" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -752,6 +763,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Categories" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kategorien"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Category" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -842,6 +863,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Clear" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Löschen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Comma (e.g. 1,42)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -931,6 +962,7 @@
|
||||
}
|
||||
},
|
||||
"Connection error" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -975,6 +1007,7 @@
|
||||
}
|
||||
},
|
||||
"Cookbooks" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -1327,6 +1360,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Downloaded" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Heruntergeladen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Downloads" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -1438,6 +1482,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Enter a recipe name or keyword to get started." : {
|
||||
"comment" : "A description under the magnifying glass icon in the \"Search for recipes\" view, encouraging the user to start searching.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Gib einen Rezeptnamen oder ein Stichwort ein, um loszulegen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Error" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2457,6 +2513,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No cookbooks found" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Keine Kategorien gefunden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No keywords." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2501,6 +2567,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No recipes in this cookbook" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Keine Rezepte in dieser Kategorie"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No results found" : {
|
||||
"comment" : "A message indicating that no recipes were found for the current search query.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Keine Ergebnisse gefunden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"None" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2633,6 +2721,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"On server" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auf dem Server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Other" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2656,6 +2755,7 @@
|
||||
}
|
||||
},
|
||||
"Parsing error" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -2722,6 +2822,7 @@
|
||||
}
|
||||
},
|
||||
"Please check the entered URL." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -2877,6 +2978,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pull to refresh or check your server connection." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zum Aktualisieren nach unten ziehen oder Serververbindung prüfen."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recent searches" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Letzte Suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recently Viewed" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zuletzt angesehen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipe" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2965,6 +3096,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipes will appear here once they are added to this category." : {
|
||||
"comment" : "A description of what will happen when a user adds a recipe to a category.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rezepte werden hier angezeigt, sobald sie dieser Kategorie hinzugefügt werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Refresh" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3076,6 +3219,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search for recipes" : {
|
||||
"comment" : "A prompt displayed when the search text is empty, encouraging the user to enter a search term.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Rezepte suchen"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search recipe" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3120,6 +3275,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Search Results" : {
|
||||
"comment" : "The title of the view that lists search results for recipes.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Suchergebnisse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select a default cookbook" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3570,7 +3737,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "SwiftSoup"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -3677,6 +3844,7 @@
|
||||
}
|
||||
},
|
||||
"There are no recipes in this cookbook!" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -3788,6 +3956,7 @@
|
||||
}
|
||||
},
|
||||
"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" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -3926,7 +4095,7 @@
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : ""
|
||||
"value" : "TPPDF"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
@@ -3966,6 +4135,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Try a different search term." : {
|
||||
"comment" : "A message suggesting a different search term if no results are found.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Versuche einen anderen Suchbegriff."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unable to complete action." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -4011,6 +4192,7 @@
|
||||
}
|
||||
},
|
||||
"Unable to load website content. Please check your internet connection." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -4299,5 +4481,5 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
"version" : "1.1"
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//
|
||||
// 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: RecipeDetail = RecipeDetail()
|
||||
|
||||
@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: RecipeDetail, 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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,10 @@ struct ApiRequest {
|
||||
|
||||
// Prepare URL
|
||||
let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path
|
||||
print("Full path: \(urlString)")
|
||||
//Logger.network.debug("Full path: \(urlString)")
|
||||
guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) }
|
||||
guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) }
|
||||
guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) }
|
||||
// Ensure path percent encoding is applied correctly
|
||||
components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path
|
||||
guard let url = components.url else { return (nil, .missingUrl) }
|
||||
|
||||
// Create URL request
|
||||
var request = URLRequest(url: url)
|
||||
@@ -65,39 +65,31 @@ struct ApiRequest {
|
||||
}
|
||||
|
||||
// Wait for and return data and (decoded) response
|
||||
var data: Data? = nil
|
||||
var response: URLResponse? = nil
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
||||
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
|
||||
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
||||
let (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) FAILURE: \(error.localizedDescription)")
|
||||
return (nil, error)
|
||||
}
|
||||
if let data = data {
|
||||
print(data, String(data: data, encoding: .utf8) as Any)
|
||||
return (data, nil)
|
||||
}
|
||||
return (nil, .unknownError)
|
||||
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
||||
return (data, nil)
|
||||
} catch {
|
||||
let error = decodeURLResponse(response: response as? HTTPURLResponse)
|
||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)")
|
||||
return (nil, error)
|
||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
||||
return (nil, .connectionError(underlying: error))
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
||||
private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? {
|
||||
guard let response = response else {
|
||||
return NetworkError.unknownError
|
||||
return .unknownError(detail: "No HTTP response")
|
||||
}
|
||||
print("Status code: ", response.statusCode)
|
||||
switch response.statusCode {
|
||||
case 200...299: return (nil)
|
||||
case 300...399: return (NetworkError.redirectionError)
|
||||
case 400...499: return (NetworkError.clientError)
|
||||
case 500...599: return (NetworkError.serverError)
|
||||
case 600: return (NetworkError.invalidRequest)
|
||||
default: return (NetworkError.unknownError)
|
||||
let statusCode = response.statusCode
|
||||
switch statusCode {
|
||||
case 200...299:
|
||||
return nil
|
||||
default:
|
||||
let body = data.flatMap { String(data: $0, encoding: .utf8) }
|
||||
return .httpError(statusCode: statusCode, body: body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,169 +10,38 @@ 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 -> (RecipeDetail?, 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: Int,
|
||||
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 -> ([Recipe]?, 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: RecipeDetail
|
||||
) 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: Int
|
||||
) async -> (RecipeDetail?, 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: RecipeDetail
|
||||
) 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: Int
|
||||
) 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 -> ([Category]?, 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 -> ([Recipe]?, 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 -> ([Recipe]?, 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?)
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,212 +6,155 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import UIKit
|
||||
|
||||
|
||||
class CookbookApiV1: CookbookApi {
|
||||
static let basePath: String = "/index.php/apps/cookbook/api/v1"
|
||||
final class CookbookApiClient: CookbookApiProtocol {
|
||||
private let basePath = "/index.php/apps/cookbook/api/v1"
|
||||
private let settings: UserSettings
|
||||
|
||||
static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, 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) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
private struct RecipeImportRequest: Codable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
|
||||
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 = 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)
|
||||
let request = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE)
|
||||
let data = try await sendRaw(request)
|
||||
return UIImage(data: data)
|
||||
}
|
||||
|
||||
static func getRecipes(auth: String) async -> ([Recipe]?, 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))
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
func getRecipes() async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/recipes", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
|
||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
||||
return .dataError
|
||||
func createRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
||||
guard let body = JSONEncoder.safeEncode(recipe) else {
|
||||
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
||||
}
|
||||
|
||||
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 .serverError
|
||||
}
|
||||
} catch {
|
||||
return .decodingFailed
|
||||
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
|
||||
}
|
||||
return nil
|
||||
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
||||
}
|
||||
|
||||
static func getRecipe(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) {
|
||||
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) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
func getRecipe(id: Int) async throws -> RecipeDetail {
|
||||
let request = makeRequest(path: "/recipes/\(id)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
static func updateRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
|
||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
||||
return .dataError
|
||||
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
||||
guard let body = JSONEncoder.safeEncode(recipe) else {
|
||||
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
||||
}
|
||||
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 .serverError
|
||||
}
|
||||
} catch {
|
||||
return .decodingFailed
|
||||
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
|
||||
}
|
||||
return nil
|
||||
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
||||
}
|
||||
|
||||
static func deleteRecipe(auth: String, id: Int) async -> (NetworkError?) {
|
||||
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
|
||||
func deleteRecipe(id: Int) async throws {
|
||||
let request = makeRequest(path: "/recipes/\(id)", method: .DELETE)
|
||||
let _ = try await sendRaw(request)
|
||||
}
|
||||
|
||||
static func getCategories(auth: String) async -> ([Category]?, 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)
|
||||
func getCategories() async throws -> [Category] {
|
||||
let request = makeRequest(path: "/categories", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
static func getCategory(auth: String, named categoryName: String) async -> ([Recipe]?, 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) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
func getCategory(named categoryName: String) async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/category/\(categoryName)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
func getTags() async throws -> [RecipeKeyword] {
|
||||
let request = makeRequest(path: "/keywords", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
static func getRecipesTagged(auth: String, keyword: String) async -> ([Recipe]?, 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) }
|
||||
return (JSONDecoder.safeDecode(data), nil)
|
||||
func getRecipesTagged(keyword: String) async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/tags/\(keyword)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
|
||||
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
|
||||
func searchRecipes(query: String) async throws -> [Recipe] {
|
||||
let request = makeRequest(path: "/search/\(query)", method: .GET)
|
||||
return try await sendAndDecode(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,45 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum NetworkError: String, Error {
|
||||
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 = "Client error"
|
||||
case serverError = "Server error"
|
||||
case invalidRequest = "Invalid request"
|
||||
case unknownError = "Unknown error"
|
||||
case dataError = "Invalid data error."
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -44,7 +44,3 @@ struct HeaderField {
|
||||
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecipeImportRequest: Codable {
|
||||
let url: String
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
|
||||
@@ -33,10 +34,10 @@ class NextcloudApi {
|
||||
return (nil, error)
|
||||
}
|
||||
guard let data = data else {
|
||||
return (nil, NetworkError.dataError)
|
||||
return (nil, NetworkError.encodingFailed())
|
||||
}
|
||||
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
|
||||
return (nil, NetworkError.decodingFailed)
|
||||
return (nil, NetworkError.decodingFailed())
|
||||
}
|
||||
return (loginRequest, nil)
|
||||
}
|
||||
@@ -69,10 +70,10 @@ class NextcloudApi {
|
||||
return (nil, error)
|
||||
}
|
||||
guard let data = data else {
|
||||
return (nil, NetworkError.dataError)
|
||||
return (nil, NetworkError.encodingFailed())
|
||||
}
|
||||
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
|
||||
return (nil, NetworkError.decodingFailed)
|
||||
return (nil, NetworkError.decodingFailed())
|
||||
}
|
||||
return (loginResponse, nil)
|
||||
}
|
||||
@@ -107,11 +108,11 @@ class NextcloudApi {
|
||||
userId: data?["userId"] as? String ?? "",
|
||||
userDisplayName: data?["displayName"] as? String ?? ""
|
||||
)
|
||||
print(userData)
|
||||
Logger.network.debug("Loaded hover card for user \(userData.userId)")
|
||||
return (userData, nil)
|
||||
} catch {
|
||||
print(error.localizedDescription)
|
||||
return (nil, NetworkError.decodingFailed)
|
||||
Logger.network.error("Failed to decode hover card: \(error.localizedDescription)")
|
||||
return (nil, NetworkError.decodingFailed())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
//
|
||||
// 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 -> (RecipeDetail?, 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>) -> RecipeDetail? {
|
||||
|
||||
var recipeDetail = RecipeDetail()
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
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()
|
||||
@@ -1,4 +0,0 @@
|
||||
<?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>
|
||||
@@ -94,33 +94,6 @@ 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 {
|
||||
case REQUEST_DELAYED,
|
||||
REQUEST_DROPPED,
|
||||
|
||||
@@ -15,46 +15,48 @@ struct MainView: View {
|
||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
||||
|
||||
@State private var selectedTab: Tab = .recipes
|
||||
|
||||
enum Tab {
|
||||
case recipes, search, groceryList
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
RecipeTabView()
|
||||
.environmentObject(recipeViewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.tabItem {
|
||||
Label("Recipes", systemImage: "book.closed.fill")
|
||||
}
|
||||
.tag(Tab.recipes)
|
||||
TabView(selection: $selectedTab) {
|
||||
SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) {
|
||||
RecipeTabView()
|
||||
.environmentObject(recipeViewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
|
||||
SearchTabView()
|
||||
.environmentObject(searchViewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
}
|
||||
.tag(Tab.search)
|
||||
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
||||
SearchTabView()
|
||||
.environmentObject(searchViewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
|
||||
GroceryListTabView()
|
||||
.environmentObject(groceryList)
|
||||
.tabItem {
|
||||
if #available(iOS 17.0, *) {
|
||||
Label("Grocery List", systemImage: "storefront")
|
||||
} else {
|
||||
Label("Grocery List", systemImage: "heart.text.square")
|
||||
}
|
||||
}
|
||||
.tag(Tab.groceryList)
|
||||
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) {
|
||||
GroceryListTabView()
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.modifier(TabBarMinimizeModifier())
|
||||
.task {
|
||||
recipeViewModel.presentLoadingIndicator = true
|
||||
await appState.getCategories()
|
||||
await appState.updateAllRecipeDetails()
|
||||
|
||||
// Preload category images
|
||||
for category in appState.categories {
|
||||
await appState.getCategoryImage(for: category.name)
|
||||
}
|
||||
|
||||
// Load recently viewed recipes
|
||||
await appState.loadRecentRecipes()
|
||||
|
||||
// Open detail view for default category
|
||||
if UserSettings.shared.defaultCategory != "" {
|
||||
if let cat = appState.categories.first(where: { c in
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingView: View {
|
||||
@@ -148,7 +149,7 @@ struct BorderedLoginTextField: View {
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.foregroundColor(color)
|
||||
.accentColor(color)
|
||||
.tint(color)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
@@ -170,7 +171,7 @@ struct LoginTextField: View {
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.foregroundColor(color)
|
||||
.accentColor(color)
|
||||
.tint(color)
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
@@ -203,7 +204,7 @@ struct ServerAddressField: View {
|
||||
.tint(.white)
|
||||
.font(.headline)
|
||||
.onChange(of: serverProtocol) { value in
|
||||
print(value)
|
||||
Logger.view.debug("\(value.rawValue)")
|
||||
userSettings.serverProtocol = value.rawValue
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -72,7 +73,7 @@ struct TokenLoginView: View {
|
||||
case .username:
|
||||
focusedField = .token
|
||||
default:
|
||||
print("Attempting to log in ...")
|
||||
Logger.view.debug("Attempting to log in ...")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,19 +90,14 @@ struct TokenLoginView: View {
|
||||
}
|
||||
|
||||
UserSettings.shared.setAuthString()
|
||||
let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString)
|
||||
|
||||
if let error = error {
|
||||
let client = CookbookApiFactory.makeClient()
|
||||
do {
|
||||
let _ = try await client.getCategories()
|
||||
return true
|
||||
} catch {
|
||||
alertMessage = "Login failed. Please check your inputs and internet connection."
|
||||
showAlert = true
|
||||
return false
|
||||
}
|
||||
|
||||
guard let data = data else {
|
||||
alertMessage = "Login failed. Please check your inputs."
|
||||
showAlert = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@@ -82,7 +83,7 @@ struct V2LoginView: View {
|
||||
Task {
|
||||
let error = await sendLoginV2Request()
|
||||
if let error = error {
|
||||
alertMessage = "A network error occured (\(error.rawValue))."
|
||||
alertMessage = "A network error occured (\(error.localizedDescription))."
|
||||
showAlert = true
|
||||
}
|
||||
if let loginRequest = loginRequest {
|
||||
@@ -151,13 +152,13 @@ struct V2LoginView: View {
|
||||
}
|
||||
|
||||
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
|
||||
guard let loginRequest = loginRequest else { return (nil, .parametersNil) }
|
||||
guard let loginRequest = loginRequest else { return (nil, .invalidRequest) }
|
||||
return await NextcloudApi.loginV2Response(req: loginRequest)
|
||||
}
|
||||
|
||||
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
||||
if let error = error {
|
||||
alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))"
|
||||
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))"
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
@@ -166,7 +167,7 @@ struct V2LoginView: View {
|
||||
showAlert = true
|
||||
return
|
||||
}
|
||||
print("Login successful for user \(response.loginName)!")
|
||||
Logger.network.debug("Login successful for user \(response.loginName)!")
|
||||
UserSettings.shared.username = response.loginName
|
||||
UserSettings.shared.token = response.appPassword
|
||||
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||
@@ -187,12 +188,17 @@ struct WebViewSheet: View {
|
||||
@State var url: String
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
WebView(url: URL(string: url)!)
|
||||
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline)
|
||||
.navigationBarItems(trailing: Button("Done") {
|
||||
dismiss()
|
||||
})
|
||||
.navigationTitle("Nextcloud Login")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//
|
||||
// CategoryCardView.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CategoryCardView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let category: Category
|
||||
var isSelected: Bool = false
|
||||
|
||||
@State private var imageLoaded = false
|
||||
|
||||
private var displayName: String {
|
||||
category.name == "*" ? String(localized: "Other") : category.name
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
// Background image or gradient fallback
|
||||
if let image = appState.categoryImages[category.name] {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
||||
.clipped()
|
||||
.opacity(imageLoaded ? 1 : 0)
|
||||
.animation(.easeIn(duration: 0.3), value: imageLoaded)
|
||||
.onAppear { imageLoaded = true }
|
||||
} else {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
||||
.overlay(alignment: .center) {
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom scrim with text
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Spacer()
|
||||
LinearGradient(
|
||||
colors: [.clear, .black.opacity(0.6)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 60)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayName)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text("\(category.recipe_count) recipes")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 140)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 17)
|
||||
.stroke(isSelected ? Color.nextcloudBlue : .clear, lineWidth: 3)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
||||
.task {
|
||||
if appState.categoryImages[category.name] == nil {
|
||||
await appState.getCategoryImage(for: category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// RecentRecipesSection.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct RecentRecipesSection: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Recently Viewed")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
Spacer()
|
||||
Button {
|
||||
appState.clearRecentRecipes()
|
||||
} label: {
|
||||
Text("Clear")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
LazyHStack(spacing: 12) {
|
||||
ForEach(appState.recentRecipes) { recipe in
|
||||
NavigationLink(value: recipe) {
|
||||
RecentRecipeCard(recipe: recipe)
|
||||
.environmentObject(appState)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RecentRecipeCard: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
let recipe: Recipe
|
||||
@State private var thumbnail: UIImage?
|
||||
|
||||
private var keywordsText: String? {
|
||||
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
|
||||
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items.prefix(3).joined(separator: " \u{00B7} ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Thumbnail
|
||||
if let thumbnail {
|
||||
Image(uiImage: thumbnail)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 160, height: 120)
|
||||
.clipped()
|
||||
} else {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.frame(width: 160, height: 120)
|
||||
.overlay {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
// Text content
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(recipe.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if let keywordsText {
|
||||
Text(keywordsText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.frame(width: 160)
|
||||
.background(Color.backgroundHighlight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||
.task {
|
||||
thumbnail = await appState.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,52 +12,64 @@ struct RecipeCardView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State var recipe: Recipe
|
||||
@State var recipeThumb: UIImage?
|
||||
@State var isDownloaded: Bool? = nil
|
||||
|
||||
private var keywordsText: String? {
|
||||
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
|
||||
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items.prefix(3).joined(separator: " \u{00B7} ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Thumbnail
|
||||
if let recipeThumb = recipeThumb {
|
||||
Image(uiImage: recipeThumb)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
|
||||
.clipped()
|
||||
} 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()
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
|
||||
.overlay {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
}
|
||||
|
||||
// Text content
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(recipe.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
if let keywordsText {
|
||||
Text(keywordsText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.background(Color.backgroundHighlight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||
.task {
|
||||
recipeThumb = await appState.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||
)
|
||||
if recipe.storedLocally == nil {
|
||||
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
|
||||
}
|
||||
isDownloaded = recipe.storedLocally
|
||||
}
|
||||
.refreshable {
|
||||
recipeThumb = await appState.getImage(
|
||||
@@ -66,6 +78,5 @@ struct RecipeCardView: View {
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
||||
)
|
||||
}
|
||||
.frame(height: 80)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,38 +18,55 @@ struct RecipeListView: View {
|
||||
@Binding var showEditView: Bool
|
||||
@State var selectedRecipe: Recipe? = nil
|
||||
|
||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
let recipes = recipesFiltered()
|
||||
if !recipes.isEmpty {
|
||||
List(recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
.background(
|
||||
NavigationLink(value: recipe) {
|
||||
EmptyView()
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("\(recipes.count) recipes")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ForEach(recipes, id: \.recipe_id) { recipe in
|
||||
NavigationLink(value: recipe) {
|
||||
RecipeCardView(recipe: recipe)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
)
|
||||
.frame(height: 85)
|
||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||
.listRowSeparatorTint(.clear)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
} else {
|
||||
VStack {
|
||||
Text("There are no recipes in this cookbook!")
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "fork.knife")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No recipes in this cookbook")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Recipes will appear here once they are added to this category.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
Button {
|
||||
Task {
|
||||
await appState.getCategories()
|
||||
await appState.getCategory(named: categoryName, fetchMode: .preferServer)
|
||||
}
|
||||
} label: {
|
||||
Text("Refresh")
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
.bold()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.nextcloudBlue)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
@@ -64,7 +81,6 @@ struct RecipeListView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
print("Add new recipe")
|
||||
showEditView = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -167,6 +168,9 @@ struct RecipeView: View {
|
||||
) ?? RecipeDetail.error
|
||||
viewModel.setupView(recipeDetail: recipeDetail)
|
||||
|
||||
// Track as recently viewed
|
||||
appState.addToRecentRecipes(viewModel.recipe)
|
||||
|
||||
// Show download badge
|
||||
if viewModel.recipe.storedLocally == nil {
|
||||
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
|
||||
@@ -291,22 +295,17 @@ extension RecipeView {
|
||||
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
|
||||
if let scrapedRecipe = 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
|
||||
}
|
||||
|
||||
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
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,7 +370,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
||||
}
|
||||
|
||||
Button {
|
||||
print("Sharing recipe ...")
|
||||
Logger.view.debug("Sharing recipe ...")
|
||||
viewModel.presentShareSheet = true
|
||||
} label: {
|
||||
Label("Share Recipe", systemImage: "square.and.arrow.up")
|
||||
@@ -386,34 +385,70 @@ struct RecipeViewToolBar: ToolbarContent {
|
||||
|
||||
func handleUpload() async {
|
||||
if viewModel.newRecipe {
|
||||
print("Uploading new recipe.")
|
||||
if let recipeValidationError = recipeValid() {
|
||||
viewModel.presentAlert(recipeValidationError)
|
||||
return
|
||||
}
|
||||
// Check if the recipe was already created on the server by import
|
||||
let importedId = Int(viewModel.observableRecipeDetail.id) ?? 0
|
||||
let alreadyCreatedByImport = importedId > 0
|
||||
|
||||
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) {
|
||||
viewModel.presentAlert(alert)
|
||||
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 {
|
||||
viewModel.presentAlert(alert)
|
||||
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 {
|
||||
print("Uploading changed recipe.")
|
||||
Logger.view.debug("Uploading changed recipe.")
|
||||
|
||||
guard let _ = Int(viewModel.observableRecipeDetail.id) else {
|
||||
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
|
||||
return
|
||||
}
|
||||
|
||||
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) {
|
||||
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
|
||||
if let alert {
|
||||
viewModel.presentAlert(alert)
|
||||
return
|
||||
}
|
||||
}
|
||||
await appState.getCategories()
|
||||
await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer)
|
||||
let newCategory = viewModel.observableRecipeDetail.recipeCategory.isEmpty ? "*" : viewModel.observableRecipeDetail.recipeCategory
|
||||
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) {
|
||||
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.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ struct EditableListView: View {
|
||||
@State var axis: Axis = .vertical
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
List {
|
||||
if items.isEmpty {
|
||||
@@ -101,12 +101,15 @@ struct EditableListView: View {
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationBarTitle(title, displayMode: .inline)
|
||||
.navigationBarItems(
|
||||
trailing: Button(action: { isPresented = false }) {
|
||||
Text("Done")
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(action: { isPresented = false }) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,7 @@ struct RecipeIngredientSection: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
if #available(iOS 17.0, *) {
|
||||
Image(systemName: "storefront")
|
||||
} else {
|
||||
Image(systemName: "heart.text.square")
|
||||
}
|
||||
Image(systemName: "storefront")
|
||||
}.disabled(viewModel.editMode)
|
||||
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||
@@ -111,14 +107,8 @@ fileprivate struct IngredientListItem: View {
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||
if #available(iOS 17.0, *) {
|
||||
Image(systemName: "storefront")
|
||||
.foregroundStyle(Color.green)
|
||||
} else {
|
||||
Image(systemName: "heart.text.square")
|
||||
.foregroundStyle(Color.green)
|
||||
}
|
||||
|
||||
Image(systemName: "storefront")
|
||||
.foregroundStyle(Color.green)
|
||||
} else if isSelected {
|
||||
Image(systemName: "checkmark.circle")
|
||||
} else {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import AVFoundation
|
||||
@@ -55,8 +56,13 @@ struct TimerView: View {
|
||||
.bold()
|
||||
.padding()
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.foregroundStyle(.ultraThickMaterial)
|
||||
if #available(iOS 26, *) {
|
||||
Color.clear
|
||||
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.foregroundStyle(.ultraThickMaterial)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,7 +153,7 @@ extension RecipeTimer {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("Failed to set audio session category. Error: \(error)")
|
||||
Logger.view.error("Failed to set audio session category. Error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +164,7 @@ extension RecipeTimer {
|
||||
audioPlayer?.prepareToPlay()
|
||||
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
||||
} catch {
|
||||
print("Error loading sound file: \(error)")
|
||||
Logger.view.error("Error loading sound file: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,9 +186,9 @@ extension RecipeTimer {
|
||||
func requestNotificationPermissions() {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
if granted {
|
||||
print("Notification permission granted.")
|
||||
Logger.view.debug("Notification permission granted.")
|
||||
} else if let error = error {
|
||||
print("Notification permission denied because: \(error.localizedDescription).")
|
||||
Logger.view.error("Notification permission denied because: \(error.localizedDescription).")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,7 +205,7 @@ extension RecipeTimer {
|
||||
|
||||
UNUserNotificationCenter.current().add(request) { error in
|
||||
if let error = error {
|
||||
print("Error scheduling notification: \(error)")
|
||||
Logger.view.error("Error scheduling notification: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -135,7 +136,7 @@ struct SettingsView: View {
|
||||
|
||||
Section {
|
||||
Button("Log out") {
|
||||
print("Log out.")
|
||||
Logger.view.debug("Log out.")
|
||||
viewModel.alertType = .LOG_OUT
|
||||
viewModel.showAlert = true
|
||||
|
||||
@@ -143,7 +144,7 @@ struct SettingsView: View {
|
||||
.tint(.red)
|
||||
|
||||
Button("Delete local data") {
|
||||
print("Clear cache.")
|
||||
Logger.view.debug("Clear cache.")
|
||||
viewModel.alertType = .DELETE_CACHE
|
||||
viewModel.showAlert = true
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -150,7 +151,7 @@ class GroceryRecipeItem: Identifiable, Codable {
|
||||
|
||||
|
||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
|
||||
print("Adding item of recipe \(String(describing: recipeName))")
|
||||
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))
|
||||
@@ -174,7 +175,7 @@ class GroceryRecipeItem: Identifiable, Codable {
|
||||
}
|
||||
|
||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
||||
print("Deleting item \(itemName)")
|
||||
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)
|
||||
@@ -186,20 +187,20 @@ class GroceryRecipeItem: Identifiable, Codable {
|
||||
}
|
||||
|
||||
func deleteGroceryRecipe(_ recipeId: String) {
|
||||
print("Deleting grocery recipe with id \(recipeId)")
|
||||
Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
|
||||
groceryDict.removeValue(forKey: recipeId)
|
||||
save()
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func deleteAll() {
|
||||
print("Deleting all grocery items")
|
||||
Logger.view.debug("Deleting all grocery items")
|
||||
groceryDict = [:]
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
||||
print("Item checked: \(groceryItem.name)")
|
||||
Logger.view.debug("Item checked: \(groceryItem.name)")
|
||||
groceryItem.isChecked.toggle()
|
||||
save()
|
||||
}
|
||||
@@ -229,7 +230,7 @@ class GroceryRecipeItem: Identifiable, Codable {
|
||||
) else { return }
|
||||
self.groceryDict = groceryDict
|
||||
} catch {
|
||||
print("Unable to load grocery list")
|
||||
Logger.view.error("Unable to load grocery list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -13,44 +14,66 @@ struct RecipeTabView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(selection: $viewModel.selectedCategory) {
|
||||
// Categories
|
||||
ForEach(appState.categories) { category in
|
||||
NavigationLink(value: category) {
|
||||
HStack(alignment: .center) {
|
||||
if viewModel.selectedCategory != nil &&
|
||||
category.name == viewModel.selectedCategory!.name {
|
||||
Image(systemName: "book")
|
||||
} else {
|
||||
Image(systemName: "book.closed.fill")
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Recently Viewed
|
||||
if !appState.recentRecipes.isEmpty {
|
||||
RecentRecipesSection()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
// Categories header
|
||||
if !appState.categories.isEmpty {
|
||||
Text("Categories")
|
||||
.font(.title2)
|
||||
.bold()
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
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)
|
||||
// Category grid
|
||||
if appState.categories.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No cookbooks found")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Pull to refresh or check your server connection.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 40)
|
||||
} else {
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ForEach(appState.categories) { category in
|
||||
Button {
|
||||
viewModel.selectedCategory = category
|
||||
if horizontalSizeClass == .compact {
|
||||
viewModel.navigateToCategory = true
|
||||
}
|
||||
} label: {
|
||||
CategoryCardView(
|
||||
category: category,
|
||||
isSelected: viewModel.selectedCategory?.name == category.name
|
||||
)
|
||||
}
|
||||
}.padding(7)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Cookbooks")
|
||||
.navigationTitle("Recipes")
|
||||
.toolbar {
|
||||
RecipeTabViewToolBar()
|
||||
}
|
||||
@@ -63,6 +86,22 @@ struct RecipeTabView: View {
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
.navigationDestination(isPresented: $viewModel.navigateToCategory) {
|
||||
if let category = viewModel.selectedCategory {
|
||||
RecipeListView(
|
||||
categoryName: category.name,
|
||||
showEditView: $viewModel.presentEditView
|
||||
)
|
||||
.id(category.id)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
if let category = viewModel.selectedCategory {
|
||||
@@ -70,9 +109,8 @@ struct RecipeTabView: View {
|
||||
categoryName: category.name,
|
||||
showEditView: $viewModel.presentEditView
|
||||
)
|
||||
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
||||
.id(category.id)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.tint(.nextcloudBlue)
|
||||
@@ -88,12 +126,17 @@ struct RecipeTabView: View {
|
||||
viewModel.serverConnection = connection
|
||||
}
|
||||
await appState.getCategories()
|
||||
for category in appState.categories {
|
||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||
await appState.getCategoryImage(for: category.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var presentEditView: Bool = false
|
||||
@Published var presentSettingsView: Bool = false
|
||||
@Published var navigateToCategory: Bool = false
|
||||
|
||||
@Published var presentLoadingIndicator: Bool = false
|
||||
@Published var presentConnectionPopover: Bool = false
|
||||
@@ -143,7 +186,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||
// Server connection indicator
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
print("Check server connection")
|
||||
Logger.view.debug("Check server connection")
|
||||
viewModel.presentConnectionPopover = true
|
||||
} label: {
|
||||
if viewModel.presentLoadingIndicator {
|
||||
@@ -170,7 +213,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||
// Create new recipes
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
print("Add new recipe")
|
||||
Logger.view.debug("Add new recipe")
|
||||
viewModel.presentEditView = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
|
||||
@@ -14,28 +14,110 @@ struct SearchTabView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
.background(
|
||||
NavigationLink(value: recipe) {
|
||||
EmptyView()
|
||||
List {
|
||||
let results = viewModel.recipesFiltered()
|
||||
|
||||
if viewModel.searchText.isEmpty {
|
||||
// Icon + explainer
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Search for recipes")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Enter a recipe name or keyword to get started.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
// Search history
|
||||
if !viewModel.searchHistory.isEmpty {
|
||||
Section {
|
||||
ForEach(viewModel.searchHistory, id: \.self) { term in
|
||||
Button {
|
||||
viewModel.searchText = term
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline)
|
||||
Text(term)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
)
|
||||
.frame(height: 85)
|
||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||
.listRowSeparatorTint(.clear)
|
||||
.onDelete { offsets in
|
||||
viewModel.removeHistory(at: offsets)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Recent searches")
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.clearHistory()
|
||||
} label: {
|
||||
Text("Clear")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if results.isEmpty {
|
||||
// No results
|
||||
Section {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No results found")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Try a different search term.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
} else {
|
||||
// Results
|
||||
Section {
|
||||
ForEach(results, id: \.recipe_id) { recipe in
|
||||
SearchRecipeRow(recipe: recipe)
|
||||
.background(
|
||||
NavigationLink(value: recipe) {
|
||||
EmptyView()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.opacity(0)
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 15, bottom: 6, trailing: 15))
|
||||
.listRowSeparatorTint(.clear)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||
}
|
||||
.navigationTitle("Search recipe")
|
||||
.listStyle(.plain)
|
||||
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||
.onSubmit(of: .search) {
|
||||
viewModel.saveToHistory(viewModel.searchText)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel.allRecipes.isEmpty {
|
||||
@@ -51,24 +133,114 @@ struct SearchTabView: View {
|
||||
@Published var allRecipes: [Recipe] = []
|
||||
@Published var searchText: String = ""
|
||||
@Published var searchMode: SearchMode = .name
|
||||
@Published var searchHistory: [String] = []
|
||||
|
||||
private static let historyKey = "searchHistory"
|
||||
private static let maxHistory = 15
|
||||
|
||||
init() {
|
||||
self.searchHistory = UserDefaults.standard.stringArray(forKey: Self.historyKey) ?? []
|
||||
}
|
||||
|
||||
enum SearchMode: String, CaseIterable {
|
||||
case name = "Name & Keywords", ingredient = "Ingredients"
|
||||
}
|
||||
|
||||
func recipesFiltered() -> [Recipe] {
|
||||
guard searchText != "" else { return [] }
|
||||
if searchMode == .name {
|
||||
guard searchText != "" else { return allRecipes }
|
||||
return allRecipes.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
|
||||
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
||||
}
|
||||
} else if searchMode == .ingredient {
|
||||
// TODO: Fuzzy ingredient search
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
func saveToHistory(_ term: String) {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
searchHistory.removeAll { $0.lowercased() == trimmed.lowercased() }
|
||||
searchHistory.insert(trimmed, at: 0)
|
||||
if searchHistory.count > Self.maxHistory {
|
||||
searchHistory = Array(searchHistory.prefix(Self.maxHistory))
|
||||
}
|
||||
UserDefaults.standard.set(searchHistory, forKey: Self.historyKey)
|
||||
}
|
||||
|
||||
func removeHistory(at offsets: IndexSet) {
|
||||
searchHistory.remove(atOffsets: offsets)
|
||||
UserDefaults.standard.set(searchHistory, forKey: Self.historyKey)
|
||||
}
|
||||
|
||||
func clearHistory() {
|
||||
searchHistory = []
|
||||
UserDefaults.standard.removeObject(forKey: Self.historyKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Horizontal row card for search results
|
||||
|
||||
private struct SearchRecipeRow: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State var recipe: Recipe
|
||||
@State private var recipeThumb: UIImage?
|
||||
|
||||
private var keywordsText: String? {
|
||||
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
|
||||
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items.prefix(3).joined(separator: " \u{00B7} ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
if let recipeThumb {
|
||||
Image(uiImage: recipeThumb)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 70, height: 70)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.frame(width: 70, height: 70)
|
||||
.overlay {
|
||||
Image(systemName: "fork.knife")
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(recipe.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(2)
|
||||
|
||||
if let keywordsText {
|
||||
Text(keywordsText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.task {
|
||||
recipeThumb = await appState.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user