Compare commits
2 Commits
main
...
7c824b492e
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||||
@@ -26,13 +26,10 @@
|
|||||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
||||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
|
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
|
||||||
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
|
|
||||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
|
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
|
||||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
|
||||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
||||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
|
|
||||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
||||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
||||||
@@ -40,6 +37,7 @@
|
|||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
||||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
||||||
|
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; };
|
||||||
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
|
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
|
||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||||
@@ -109,12 +107,10 @@
|
|||||||
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
|
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
|
||||||
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
||||||
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; };
|
|
||||||
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
|
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
|
||||||
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
||||||
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
|
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
|
||||||
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
||||||
@@ -122,6 +118,7 @@
|
|||||||
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
||||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
||||||
|
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = "<group>"; };
|
||||||
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
|
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||||
@@ -155,7 +152,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
|
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -261,7 +257,6 @@
|
|||||||
A70171B72AB2445700064C43 /* Models */ = {
|
A70171B72AB2445700064C43 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -312,7 +307,6 @@
|
|||||||
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
|
|
||||||
);
|
);
|
||||||
path = RecipeImport;
|
path = RecipeImport;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -401,6 +395,7 @@
|
|||||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
||||||
|
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */,
|
||||||
);
|
);
|
||||||
path = ReusableViews;
|
path = ReusableViews;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -438,7 +433,6 @@
|
|||||||
);
|
);
|
||||||
name = "Nextcloud Cookbook iOS Client";
|
name = "Nextcloud Cookbook iOS Client";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
|
|
||||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
|
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
|
||||||
);
|
);
|
||||||
productName = "Nextcloud Cookbook iOS Client";
|
productName = "Nextcloud Cookbook iOS Client";
|
||||||
@@ -517,7 +511,6 @@
|
|||||||
);
|
);
|
||||||
mainGroup = A70171752AA8E71900064C43;
|
mainGroup = A70171752AA8E71900064C43;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
|
||||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
||||||
);
|
);
|
||||||
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
||||||
@@ -572,7 +565,7 @@
|
|||||||
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
|
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
|
||||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
||||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
||||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
||||||
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
||||||
@@ -586,6 +579,7 @@
|
|||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
||||||
|
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */,
|
||||||
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
||||||
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
|
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
|
||||||
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
|
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
|
||||||
@@ -609,7 +603,6 @@
|
|||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
||||||
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
|
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
|
||||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
||||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
|
||||||
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
|
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
||||||
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
||||||
@@ -793,7 +786,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
@@ -837,7 +830,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
@@ -863,7 +856,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||||
@@ -887,7 +880,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||||
@@ -910,7 +903,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||||
@@ -933,7 +926,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||||
@@ -989,14 +982,6 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 2.6.1;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = {
|
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/techprimate/TPPDF.git";
|
repositoryURL = "https://github.com/techprimate/TPPDF.git";
|
||||||
@@ -1008,11 +993,6 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
A74D33BD2AF82AAE00D06555 /* SwiftSoup */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
|
||||||
productName = SwiftSoup;
|
|
||||||
};
|
|
||||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
|
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
|
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"originHash" : "314ca0b5cf5f134470eb4e9e12133500ae78d8b9a08f490e0065f2b3ceb4a25a",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
|
||||||
"identity" : "swiftsoup",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
|
|
||||||
"version" : "2.6.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "tppdf",
|
"identity" : "tppdf",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -19,5 +11,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -21,10 +22,12 @@ import UIKit
|
|||||||
var allKeywords: [RecipeKeyword] = []
|
var allKeywords: [RecipeKeyword] = []
|
||||||
|
|
||||||
private let dataStore: DataStore
|
private let dataStore: DataStore
|
||||||
|
private let api: CookbookApiProtocol
|
||||||
|
|
||||||
init() {
|
init(api: CookbookApiProtocol? = nil) {
|
||||||
print("Created MainViewModel")
|
Logger.network.debug("Created AppState")
|
||||||
self.dataStore = DataStore()
|
self.dataStore = DataStore()
|
||||||
|
self.api = api ?? CookbookApiFactory.makeClient()
|
||||||
|
|
||||||
if UserSettings.shared.authString == "" {
|
if UserSettings.shared.authString == "" {
|
||||||
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||||
@@ -38,54 +41,33 @@ import UIKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
// MARK: - Categories
|
||||||
Asynchronously loads and updates the list of categories.
|
|
||||||
|
|
||||||
This function attempts to fetch the list of categories from the server. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it attempts to load the categories from local storage.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
*/
|
|
||||||
func getCategories() async {
|
func getCategories() async {
|
||||||
let (categories, _) = await cookbookApi.getCategories(
|
do {
|
||||||
auth: UserSettings.shared.authString
|
let categories = try await api.getCategories()
|
||||||
)
|
Logger.data.debug("Successfully loaded categories")
|
||||||
if let categories = categories {
|
|
||||||
print("Successfully loaded categories")
|
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
await saveLocal(self.categories, path: "categories.data")
|
await saveLocal(self.categories, path: "categories.data")
|
||||||
} else {
|
} catch {
|
||||||
// If there's no server connection, try loading categories from local storage
|
Logger.data.debug("Loading categories from store ...")
|
||||||
print("Loading categories from store ...")
|
|
||||||
if let categories: [Category] = await loadLocal(path: "categories.data") {
|
if let categories: [Category] = await loadLocal(path: "categories.data") {
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
print("Success!")
|
Logger.data.debug("Loaded categories from local store")
|
||||||
} else {
|
} 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 {
|
for category in self.categories {
|
||||||
lastUpdates[category.name] = Date.distantPast
|
lastUpdates[category.name] = Date.distantPast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
Fetches recipes for a specified category from either the server or local storage.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- name: The name of the category. Use "*" to fetch recipes without assigned categories.
|
|
||||||
- needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first.
|
|
||||||
|
|
||||||
This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage.
|
|
||||||
|
|
||||||
- Note: The category name "*" is used for all uncategorized recipes.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
*/
|
|
||||||
func getCategory(named name: String, fetchMode: FetchMode) async {
|
func getCategory(named name: String, fetchMode: FetchMode) async {
|
||||||
print("getCategory(\(name), fetchMode: \(fetchMode))")
|
Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))")
|
||||||
func getLocal() async -> Bool {
|
func getLocal() async -> Bool {
|
||||||
|
let categoryString = name == "*" ? "_" : name
|
||||||
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
|
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
|
||||||
self.recipes[name] = recipes
|
self.recipes[name] = recipes
|
||||||
return true
|
return true
|
||||||
@@ -94,22 +76,19 @@ import UIKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getServer(store: Bool = false) async -> Bool {
|
func getServer(store: Bool = false) async -> Bool {
|
||||||
let (recipes, _) = await cookbookApi.getCategory(
|
let categoryString = name == "*" ? "_" : name
|
||||||
auth: UserSettings.shared.authString,
|
do {
|
||||||
named: categoryString
|
let recipes = try await api.getCategory(named: categoryString)
|
||||||
)
|
|
||||||
if let recipes = recipes {
|
|
||||||
self.recipes[name] = recipes
|
self.recipes[name] = recipes
|
||||||
if store {
|
if store {
|
||||||
await saveLocal(recipes, path: "category_\(categoryString).data")
|
await saveLocal(recipes, path: "category_\(categoryString).data")
|
||||||
}
|
}
|
||||||
//userSettings.lastUpdate = Date()
|
|
||||||
return true
|
return true
|
||||||
}
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let categoryString = name == "*" ? "_" : name
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
case .preferLocal:
|
case .preferLocal:
|
||||||
if await getLocal() { return }
|
if await getLocal() { return }
|
||||||
@@ -124,6 +103,8 @@ import UIKit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipe details
|
||||||
|
|
||||||
func updateAllRecipeDetails() async {
|
func updateAllRecipeDetails() async {
|
||||||
for category in self.categories {
|
for category in self.categories {
|
||||||
await updateRecipeDetails(in: category.name)
|
await updateRecipeDetails(in: category.name)
|
||||||
@@ -137,10 +118,10 @@ import UIKit
|
|||||||
for recipe in recipes {
|
for recipe in recipes {
|
||||||
if let dateModified = recipe.dateModified {
|
if let dateModified = recipe.dateModified {
|
||||||
if needsUpdate(category: category, lastModified: dateModified) {
|
if needsUpdate(category: category, lastModified: dateModified) {
|
||||||
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)
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||||
} else {
|
} else {
|
||||||
print("\(recipe.name) is up to date.")
|
Logger.data.debug("\(recipe.name) is up to date.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||||
@@ -148,25 +129,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] {
|
func getRecipes() async -> [Recipe] {
|
||||||
let (recipes, error) = await cookbookApi.getRecipes(
|
do {
|
||||||
auth: UserSettings.shared.authString
|
return try await api.getRecipes()
|
||||||
)
|
} catch {
|
||||||
if let recipes = recipes {
|
Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)")
|
||||||
return recipes
|
|
||||||
} else if let error = error {
|
|
||||||
print(error)
|
|
||||||
}
|
}
|
||||||
var allRecipes: [Recipe] = []
|
var allRecipes: [Recipe] = []
|
||||||
for category in categories {
|
for category in categories {
|
||||||
@@ -174,25 +141,9 @@ import UIKit
|
|||||||
allRecipes.append(contentsOf: recipeArray)
|
allRecipes.append(contentsOf: recipeArray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return allRecipes.sorted(by: {
|
return allRecipes.sorted(by: { $0.name < $1.name })
|
||||||
$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 getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
|
||||||
func getLocal() async -> RecipeDetail? {
|
func getLocal() async -> RecipeDetail? {
|
||||||
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
|
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
|
||||||
@@ -200,21 +151,18 @@ import UIKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getServer() async -> RecipeDetail? {
|
func getServer() async -> RecipeDetail? {
|
||||||
let (recipe, error) = await cookbookApi.getRecipe(
|
do {
|
||||||
auth: UserSettings.shared.authString,
|
let recipe = try await api.getRecipe(id: id)
|
||||||
id: id
|
|
||||||
)
|
|
||||||
if let recipe = recipe {
|
|
||||||
if save {
|
if save {
|
||||||
self.recipeDetails[id] = recipe
|
self.recipeDetails[id] = recipe
|
||||||
await self.saveLocal(recipe, path: "recipe\(id).data")
|
await self.saveLocal(recipe, path: "recipe\(id).data")
|
||||||
}
|
}
|
||||||
return recipe
|
return recipe
|
||||||
} else if let error = error {
|
} catch {
|
||||||
print(error)
|
Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)")
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
case .preferLocal:
|
case .preferLocal:
|
||||||
@@ -231,18 +179,6 @@ import UIKit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
Asynchronously downloads and saves details, thumbnails, and full images for all recipes.
|
|
||||||
|
|
||||||
This function iterates through all loaded categories, fetches and updates the recipes from the server, and then downloads and saves details, thumbnails, and full images for each recipe.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```swift
|
|
||||||
await mainViewModel.downloadAllRecipes()
|
|
||||||
*/
|
|
||||||
func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async {
|
func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async {
|
||||||
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
|
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
|
||||||
await saveLocal(recipeDetail, path: "recipe\(id).data")
|
await saveLocal(recipeDetail, path: "recipe\(id).data")
|
||||||
@@ -263,48 +199,24 @@ 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 {
|
func recipeDetailExists(recipeId: Int) -> Bool {
|
||||||
if (dataStore.recipeDetailExists(recipeId: recipeId)) {
|
return dataStore.recipeDetailExists(recipeId: recipeId)
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// MARK: - Images
|
||||||
Asynchronously retrieves and returns an image for a recipe with the specified ID and size.
|
|
||||||
|
|
||||||
This function attempts to fetch an image for a recipe with the specified `id` and `size` from the server using the provided `api`. If the server connection is successful, it returns the fetched image. If the server connection fails or `needsUpdate` is false, it attempts to load the image from local storage.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- id: The identifier of the recipe associated with the image.
|
|
||||||
- size: The size of the desired image (thumbnail or full).
|
|
||||||
- needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```swift
|
|
||||||
let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true)
|
|
||||||
*/
|
|
||||||
func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
|
func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
|
||||||
func getLocal() async -> UIImage? {
|
func getLocal() async -> UIImage? {
|
||||||
return await imageFromStore(id: id, size: size)
|
return await imageFromStore(id: id, size: size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getServer() async -> UIImage? {
|
func getServer() async -> UIImage? {
|
||||||
let (image, _) = await cookbookApi.getImage(
|
do {
|
||||||
auth: UserSettings.shared.authString,
|
return try await api.getImage(id: id, size: size)
|
||||||
id: id,
|
} catch {
|
||||||
size: size
|
|
||||||
)
|
|
||||||
if let image = image { return image }
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
case .preferLocal:
|
case .preferLocal:
|
||||||
@@ -356,27 +268,19 @@ import UIKit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// MARK: - Keywords
|
||||||
Asynchronously retrieves and returns a list of keywords (tags).
|
|
||||||
|
|
||||||
This function attempts to fetch a list of keywords from the server using the provided `api`. If the server connection is successful, it returns the fetched keywords. If the server connection fails, it attempts to load the keywords from local storage.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```swift
|
|
||||||
let keywords = await mainViewModel.getKeywords()
|
|
||||||
*/
|
|
||||||
func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
|
func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
|
||||||
func getLocal() async -> [RecipeKeyword]? {
|
func getLocal() async -> [RecipeKeyword]? {
|
||||||
return await loadLocal(path: "keywords.data")
|
return await loadLocal(path: "keywords.data")
|
||||||
}
|
}
|
||||||
|
|
||||||
func getServer() async -> [RecipeKeyword]? {
|
func getServer() async -> [RecipeKeyword]? {
|
||||||
let (tags, _) = await cookbookApi.getTags(
|
do {
|
||||||
auth: UserSettings.shared.authString
|
return try await api.getTags()
|
||||||
)
|
} catch {
|
||||||
return tags
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
@@ -400,6 +304,8 @@ import UIKit
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Data management
|
||||||
|
|
||||||
func deleteAllData() {
|
func deleteAllData() {
|
||||||
if dataStore.clearAll() {
|
if dataStore.clearAll() {
|
||||||
self.categories = []
|
self.categories = []
|
||||||
@@ -410,30 +316,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? {
|
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
||||||
let (error) = await cookbookApi.deleteRecipe(
|
do {
|
||||||
auth: UserSettings.shared.authString,
|
try await api.deleteRecipe(id: id)
|
||||||
id: id
|
} catch {
|
||||||
)
|
|
||||||
|
|
||||||
if let error = error {
|
|
||||||
return .REQUEST_DROPPED
|
return .REQUEST_DROPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = "recipe\(id).data"
|
let path = "recipe\(id).data"
|
||||||
dataStore.delete(path: path)
|
dataStore.delete(path: path)
|
||||||
if recipes[categoryName] != nil {
|
if recipes[categoryName] != nil {
|
||||||
@@ -445,86 +334,50 @@ import UIKit
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
Asynchronously checks the server connection by attempting to fetch categories.
|
|
||||||
|
|
||||||
This function attempts to fetch categories from the server using the provided `api` to check the server connection status. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it returns `false`.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```swift
|
|
||||||
let isConnected = await mainViewModel.checkServerConnection()
|
|
||||||
*/
|
|
||||||
func checkServerConnection() async -> Bool {
|
func checkServerConnection() async -> Bool {
|
||||||
let (categories, _) = await cookbookApi.getCategories(
|
do {
|
||||||
auth: UserSettings.shared.authString
|
let categories = try await api.getCategories()
|
||||||
)
|
|
||||||
if let categories = categories {
|
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
await saveLocal(categories, path: "categories.data")
|
await saveLocal(categories, path: "categories.data")
|
||||||
return true
|
return true
|
||||||
}
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) {
|
||||||
Asynchronously uploads a recipe to the server.
|
do {
|
||||||
|
|
||||||
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 {
|
if createNew {
|
||||||
error = await cookbookApi.createRecipe(
|
let id = try await api.createRecipe(recipeDetail)
|
||||||
auth: UserSettings.shared.authString,
|
return (id, nil)
|
||||||
recipe: recipeDetail
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
error = await cookbookApi.updateRecipe(
|
let id = try await api.updateRecipe(recipeDetail)
|
||||||
auth: UserSettings.shared.authString,
|
return (id, nil)
|
||||||
recipe: recipeDetail
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if error != nil {
|
} catch {
|
||||||
return .REQUEST_DROPPED
|
return (nil, .REQUEST_DROPPED)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
||||||
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
|
do {
|
||||||
let (recipeDetail, error) = await cookbookApi.importRecipe(
|
let recipeDetail = try await api.importRecipe(url: url)
|
||||||
auth: UserSettings.shared.authString,
|
return (recipeDetail, nil)
|
||||||
data: data
|
} catch {
|
||||||
)
|
|
||||||
if error != nil {
|
|
||||||
return (nil, .REQUEST_DROPPED)
|
return (nil, .REQUEST_DROPPED)
|
||||||
}
|
}
|
||||||
return (recipeDetail, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Local storage helpers
|
||||||
|
|
||||||
extension AppState {
|
extension AppState {
|
||||||
func loadLocal<T: Codable>(path: String) async -> T? {
|
func loadLocal<T: Codable>(path: String) async -> T? {
|
||||||
do {
|
do {
|
||||||
return try await dataStore.load(fromPath: path)
|
return try await dataStore.load(fromPath: path)
|
||||||
} catch (let error) {
|
} catch {
|
||||||
print(error)
|
Logger.data.debug("Failed to load local data: \(error.localizedDescription)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -542,7 +395,7 @@ extension AppState {
|
|||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Could not find image in local storage.")
|
Logger.data.debug("Could not find image in local storage.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -584,24 +437,20 @@ extension AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func needsUpdate(category: String, lastModified: String) -> Bool {
|
private func needsUpdate(category: String, lastModified: String) -> Bool {
|
||||||
print("=======================")
|
|
||||||
print("original date string: \(lastModified)")
|
|
||||||
// Create a DateFormatter
|
|
||||||
let dateFormatter = DateFormatter()
|
let dateFormatter = DateFormatter()
|
||||||
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
// Convert the string to a Date object
|
|
||||||
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
|
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
|
||||||
if date < lastUpdate {
|
if date < lastUpdate {
|
||||||
print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
|
Logger.data.debug("No update needed for \(category)")
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
|
Logger.data.debug("Update needed for \(category)")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
print("String is not a date. Update needed.")
|
Logger.data.debug("Date parse failed, update needed for \(category)")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ struct Category: Codable {
|
|||||||
|
|
||||||
extension Category: Identifiable, Hashable {
|
extension Category: Identifiable, Hashable {
|
||||||
var id: String { name }
|
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 Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class DataStore {
|
class DataStore {
|
||||||
@@ -52,7 +53,7 @@ class DataStore {
|
|||||||
do {
|
do {
|
||||||
_ = try await task.value
|
_ = try await task.value
|
||||||
} catch {
|
} 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 {
|
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 }
|
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
|
||||||
print("Folder path: ", folderPath)
|
|
||||||
do {
|
do {
|
||||||
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
|
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
|
||||||
for filePath in filePaths {
|
for filePath in filePaths {
|
||||||
print("File path: ", filePath)
|
|
||||||
try fileManager.removeItem(atPath: folderPath + filePath)
|
try fileManager.removeItem(atPath: folderPath + filePath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Could not delete documents folder contents: \(error)")
|
Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
print("Done.")
|
Logger.data.debug("All data deleted successfully.")
|
||||||
return true
|
return true
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class ObservableRecipeDetail: ObservableObject {
|
class ObservableRecipeDetail: ObservableObject {
|
||||||
@@ -180,7 +181,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
}
|
}
|
||||||
return foundMatches
|
return foundMatches
|
||||||
} catch {
|
} catch {
|
||||||
print("Regex error: \(error.localizedDescription)")
|
Logger.data.error("Regex error: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct Recipe: Codable {
|
|||||||
|
|
||||||
|
|
||||||
extension Recipe: Identifiable, Hashable {
|
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
|
// Custom decoder to handle value type ambiguity
|
||||||
private enum CodingKeys: String, CodingKey {
|
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 {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
name = try container.decode(String.self, forKey: .name)
|
name = try container.decode(String.self, forKey: .name)
|
||||||
keywords = try container.decode(String.self, forKey: .keywords)
|
keywords = (try? container.decode(String.self, forKey: .keywords)) ?? ""
|
||||||
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
|
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
|
||||||
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
||||||
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)
|
id = try container.decode(String.self, forKey: .id)
|
||||||
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
|
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
|
||||||
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
|
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
|
||||||
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
||||||
description = try container.decode(String.self, forKey: .description)
|
description = (try? container.decode(String.self, forKey: .description)) ?? ""
|
||||||
url = try container.decode(String.self, forKey: .url)
|
url = try? container.decode(String.self, forKey: .url)
|
||||||
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
|
recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? ""
|
||||||
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)
|
|
||||||
|
|
||||||
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 Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
extension JSONDecoder {
|
extension JSONDecoder {
|
||||||
static func safeDecode<T: Decodable>(_ data: Data) -> T? {
|
static func safeDecode<T: Decodable>(_ data: Data) -> T? {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
do {
|
do {
|
||||||
return try decoder.decode(T.self, from: data)
|
return try decoder.decode(T.self, from: data)
|
||||||
} catch (let error) {
|
} catch {
|
||||||
print(error)
|
Logger.data.error("JSONDecoder - safeDecode(): \(error.localizedDescription)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,7 +25,7 @@ extension JSONEncoder {
|
|||||||
do {
|
do {
|
||||||
return try JSONEncoder().encode(object)
|
return try JSONEncoder().encode(object)
|
||||||
} catch {
|
} catch {
|
||||||
print("JSONDecoder - safeEncode(): Could not encode object \(T.self)")
|
Logger.data.error("JSONEncoder - safeEncode(): Could not encode \(String(describing: T.self))")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,4 +16,7 @@ extension Logger {
|
|||||||
|
|
||||||
/// Network related logging
|
/// Network related logging
|
||||||
static let network = Logger(subsystem: subsystem, category: "network")
|
static let network = Logger(subsystem: subsystem, category: "network")
|
||||||
|
|
||||||
|
/// Data/persistence related logging
|
||||||
|
static let data = Logger(subsystem: subsystem, category: "data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,6 +663,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Bad URL" : {
|
"Bad URL" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -931,6 +932,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Connection error" : {
|
"Connection error" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2656,6 +2658,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Parsing error" : {
|
"Parsing error" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2722,6 +2725,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Please check the entered URL." : {
|
"Please check the entered URL." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3788,6 +3792,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." : {
|
"This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4011,6 +4016,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Unable to load website content. Please check your internet connection." : {
|
"Unable to load website content. Please check your internet connection." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@@ -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
|
// Prepare URL
|
||||||
let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path
|
let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path
|
||||||
print("Full path: \(urlString)")
|
guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) }
|
||||||
//Logger.network.debug("Full path: \(urlString)")
|
// Ensure path percent encoding is applied correctly
|
||||||
guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) }
|
components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path
|
||||||
guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) }
|
guard let url = components.url else { return (nil, .missingUrl) }
|
||||||
|
|
||||||
// Create URL request
|
// Create URL request
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
@@ -65,39 +65,31 @@ struct ApiRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for and return data and (decoded) response
|
// Wait for and return data and (decoded) response
|
||||||
var data: Data? = nil
|
|
||||||
var response: URLResponse? = nil
|
|
||||||
do {
|
do {
|
||||||
(data, response) = try await URLSession.shared.data(for: request)
|
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)
|
||||||
|
}
|
||||||
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
||||||
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
|
|
||||||
print("\(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 (data, nil)
|
||||||
}
|
|
||||||
return (nil, .unknownError)
|
|
||||||
} catch {
|
} catch {
|
||||||
let error = decodeURLResponse(response: response as? HTTPURLResponse)
|
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
||||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)")
|
return (nil, .connectionError(underlying: error))
|
||||||
return (nil, error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? {
|
||||||
guard let response = response else {
|
guard let response = response else {
|
||||||
return NetworkError.unknownError
|
return .unknownError(detail: "No HTTP response")
|
||||||
}
|
}
|
||||||
print("Status code: ", response.statusCode)
|
let statusCode = response.statusCode
|
||||||
switch response.statusCode {
|
switch statusCode {
|
||||||
case 200...299: return (nil)
|
case 200...299:
|
||||||
case 300...399: return (NetworkError.redirectionError)
|
return nil
|
||||||
case 400...499: return (NetworkError.clientError)
|
default:
|
||||||
case 500...599: return (NetworkError.serverError)
|
let body = data.flatMap { String(data: $0, encoding: .utf8) }
|
||||||
case 600: return (NetworkError.invalidRequest)
|
return .httpError(statusCode: statusCode, body: body)
|
||||||
default: return (NetworkError.unknownError)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,169 +10,38 @@ import OSLog
|
|||||||
import UIKit
|
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.
|
/// The Cookbook API version.
|
||||||
enum CookbookApiVersion: String {
|
enum CookbookApiVersion: String {
|
||||||
case v1 = "v1"
|
case v1 = "v1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A protocol defining common API endpoints that are likely to remain the same over future Cookbook API versions.
|
/// A protocol defining common API endpoints for the Cookbook API.
|
||||||
protocol CookbookApi {
|
protocol CookbookApiProtocol {
|
||||||
static var basePath: String { get }
|
func importRecipe(url: String) async throws -> RecipeDetail
|
||||||
|
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage?
|
||||||
/// Not implemented yet.
|
func getRecipes() async throws -> [Recipe]
|
||||||
static func importRecipe(
|
func createRecipe(_ recipe: RecipeDetail) async throws -> Int
|
||||||
auth: String,
|
func getRecipe(id: Int) async throws -> RecipeDetail
|
||||||
data: Data
|
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int
|
||||||
) async -> (RecipeDetail?, NetworkError?)
|
func deleteRecipe(id: Int) async throws
|
||||||
|
func getCategories() async throws -> [Category]
|
||||||
/// Get either the full image or a thumbnail sized version.
|
func getCategory(named: String) async throws -> [Recipe]
|
||||||
/// - Parameters:
|
func renameCategory(named: String, to newName: String) async throws
|
||||||
/// - auth: Server authentication string.
|
func getTags() async throws -> [RecipeKeyword]
|
||||||
/// - id: The according recipe id.
|
func getRecipesTagged(keyword: String) async throws -> [Recipe]
|
||||||
/// - size: The size of the image.
|
func searchRecipes(query: String) async throws -> [Recipe]
|
||||||
/// - 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?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 Foundation
|
||||||
|
import OSLog
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
class CookbookApiV1: CookbookApi {
|
final class CookbookApiClient: CookbookApiProtocol {
|
||||||
static let basePath: String = "/index.php/apps/cookbook/api/v1"
|
private let basePath = "/index.php/apps/cookbook/api/v1"
|
||||||
|
private let settings: UserSettings
|
||||||
|
|
||||||
static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) {
|
private struct RecipeImportRequest: Codable {
|
||||||
let request = ApiRequest(
|
let url: String
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 imageSize = (size == .FULL ? "full" : "thumb")
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE)
|
||||||
path: basePath + "/recipes/\(id)/image?size=\(imageSize)",
|
let data = try await sendRaw(request)
|
||||||
method: .GET,
|
return UIImage(data: data)
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (nil, error) }
|
|
||||||
return (UIImage(data: data), error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getRecipes(auth: String) async -> ([Recipe]?, NetworkError?) {
|
func getRecipes() async throws -> [Recipe] {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/recipes", method: .GET)
|
||||||
path: basePath + "/recipes",
|
return try await sendAndDecode(request)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
|
func createRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
||||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
guard let body = JSONEncoder.safeEncode(recipe) else {
|
||||||
return .dataError
|
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
||||||
}
|
}
|
||||||
|
let request = makeRequest(path: "/recipes", method: .POST, contentType: .JSON, body: body)
|
||||||
let request = ApiRequest(
|
let data = try await sendRaw(request)
|
||||||
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)
|
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||||
if let id = json as? Int {
|
if let id = json as? Int {
|
||||||
return nil
|
return id
|
||||||
} else if let dict = json as? [String: Any] {
|
|
||||||
return .serverError
|
|
||||||
}
|
}
|
||||||
} catch {
|
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
||||||
return .decodingFailed
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getRecipe(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) {
|
func getRecipe(id: Int) async throws -> RecipeDetail {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/recipes/\(id)", method: .GET)
|
||||||
path: basePath + "/recipes/\(id)",
|
return try await sendAndDecode(request)
|
||||||
method: .GET,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (nil, error) }
|
|
||||||
return (JSONDecoder.safeDecode(data), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func updateRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
|
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
||||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
guard let body = JSONEncoder.safeEncode(recipe) else {
|
||||||
return .dataError
|
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
||||||
}
|
}
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/recipes/\(recipe.id)", method: .PUT, contentType: .JSON, body: body)
|
||||||
path: basePath + "/recipes/\(recipe.id)",
|
let data = try await sendRaw(request)
|
||||||
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)
|
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||||
if let id = json as? Int {
|
if let id = json as? Int {
|
||||||
return nil
|
return id
|
||||||
} else if let dict = json as? [String: Any] {
|
|
||||||
return .serverError
|
|
||||||
}
|
}
|
||||||
} catch {
|
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
||||||
return .decodingFailed
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func deleteRecipe(auth: String, id: Int) async -> (NetworkError?) {
|
func deleteRecipe(id: Int) async throws {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/recipes/\(id)", method: .DELETE)
|
||||||
path: basePath + "/recipes/\(id)",
|
let _ = try await sendRaw(request)
|
||||||
method: .DELETE,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (error) }
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getCategories(auth: String) async -> ([Category]?, NetworkError?) {
|
func getCategories() async throws -> [Category] {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/categories", method: .GET)
|
||||||
path: basePath + "/categories",
|
return try await sendAndDecode(request)
|
||||||
method: .GET,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (nil, error) }
|
|
||||||
return (JSONDecoder.safeDecode(data), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getCategory(auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) {
|
func getCategory(named categoryName: String) async throws -> [Recipe] {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/category/\(categoryName)", method: .GET)
|
||||||
path: basePath + "/category/\(categoryName)",
|
return try await sendAndDecode(request)
|
||||||
method: .GET,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (nil, error) }
|
|
||||||
return (JSONDecoder.safeDecode(data), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
|
func renameCategory(named categoryName: String, to newName: String) async throws {
|
||||||
let request = ApiRequest(
|
guard let body = JSONEncoder.safeEncode(["name": newName]) else {
|
||||||
path: basePath + "/category/\(categoryName)",
|
throw NetworkError.encodingFailed(detail: "Failed to encode category name")
|
||||||
method: .PUT,
|
}
|
||||||
authString: auth,
|
let request = makeRequest(path: "/category/\(categoryName)", method: .PUT, contentType: .JSON, body: body)
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
let _ = try await sendRaw(request)
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (error) }
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getTags(auth: String) async -> ([RecipeKeyword]?, NetworkError?) {
|
func getTags() async throws -> [RecipeKeyword] {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/keywords", method: .GET)
|
||||||
path: basePath + "/keywords",
|
return try await sendAndDecode(request)
|
||||||
method: .GET,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (nil, error) }
|
|
||||||
return (JSONDecoder.safeDecode(data), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getRecipesTagged(auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) {
|
func getRecipesTagged(keyword: String) async throws -> [Recipe] {
|
||||||
let request = ApiRequest(
|
let request = makeRequest(path: "/tags/\(keyword)", method: .GET)
|
||||||
path: basePath + "/tags/\(keyword)",
|
return try await sendAndDecode(request)
|
||||||
method: .GET,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
|
||||||
)
|
|
||||||
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
guard let data = data else { return (nil, error) }
|
|
||||||
return (JSONDecoder.safeDecode(data), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getApiVersion(auth: String) async -> (NetworkError?) {
|
func searchRecipes(query: String) async throws -> [Recipe] {
|
||||||
return .none
|
let request = makeRequest(path: "/search/\(query)", method: .GET)
|
||||||
}
|
return try await sendAndDecode(request)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,45 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum NetworkError: String, Error {
|
public enum NetworkError: Error, LocalizedError {
|
||||||
case missingUrl = "Missing URL."
|
case missingUrl
|
||||||
case parametersNil = "Parameters are nil."
|
case encodingFailed(detail: String? = nil)
|
||||||
case encodingFailed = "Parameter encoding failed."
|
case decodingFailed(detail: String? = nil)
|
||||||
case decodingFailed = "Data decoding failed."
|
case httpError(statusCode: Int, body: String? = nil)
|
||||||
case redirectionError = "Redirection error"
|
case connectionError(underlying: Error? = nil)
|
||||||
case clientError = "Client error"
|
case invalidRequest
|
||||||
case serverError = "Server error"
|
case unknownError(detail: String? = nil)
|
||||||
case invalidRequest = "Invalid request"
|
|
||||||
case unknownError = "Unknown error"
|
public var errorDescription: String? {
|
||||||
case dataError = "Invalid data error."
|
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)
|
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RecipeImportRequest: Codable {
|
|
||||||
let url: String
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
|
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
|
||||||
@@ -33,10 +34,10 @@ class NextcloudApi {
|
|||||||
return (nil, error)
|
return (nil, error)
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
return (nil, NetworkError.dataError)
|
return (nil, NetworkError.encodingFailed())
|
||||||
}
|
}
|
||||||
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
|
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
|
||||||
return (nil, NetworkError.decodingFailed)
|
return (nil, NetworkError.decodingFailed())
|
||||||
}
|
}
|
||||||
return (loginRequest, nil)
|
return (loginRequest, nil)
|
||||||
}
|
}
|
||||||
@@ -69,10 +70,10 @@ class NextcloudApi {
|
|||||||
return (nil, error)
|
return (nil, error)
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
return (nil, NetworkError.dataError)
|
return (nil, NetworkError.encodingFailed())
|
||||||
}
|
}
|
||||||
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
|
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
|
||||||
return (nil, NetworkError.decodingFailed)
|
return (nil, NetworkError.decodingFailed())
|
||||||
}
|
}
|
||||||
return (loginResponse, nil)
|
return (loginResponse, nil)
|
||||||
}
|
}
|
||||||
@@ -107,11 +108,11 @@ class NextcloudApi {
|
|||||||
userId: data?["userId"] as? String ?? "",
|
userId: data?["userId"] as? String ?? "",
|
||||||
userDisplayName: data?["displayName"] as? String ?? ""
|
userDisplayName: data?["displayName"] as? String ?? ""
|
||||||
)
|
)
|
||||||
print(userData)
|
Logger.network.debug("Loaded hover card for user \(userData.userId)")
|
||||||
return (userData, nil)
|
return (userData, nil)
|
||||||
} catch {
|
} catch {
|
||||||
print(error.localizedDescription)
|
Logger.network.error("Failed to decode hover card: \(error.localizedDescription)")
|
||||||
return (nil, NetworkError.decodingFailed)
|
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 {
|
enum RequestAlert: UserAlert {
|
||||||
case REQUEST_DELAYED,
|
case REQUEST_DELAYED,
|
||||||
REQUEST_DROPPED,
|
REQUEST_DROPPED,
|
||||||
|
|||||||
@@ -15,41 +15,35 @@ struct MainView: View {
|
|||||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||||
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
||||||
|
|
||||||
|
@State private var selectedTab: Tab = .recipes
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
case recipes, search, groceryList
|
case recipes, search, groceryList
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView(selection: $selectedTab) {
|
||||||
|
SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) {
|
||||||
RecipeTabView()
|
RecipeTabView()
|
||||||
.environmentObject(recipeViewModel)
|
.environmentObject(recipeViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.tabItem {
|
|
||||||
Label("Recipes", systemImage: "book.closed.fill")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.recipes)
|
|
||||||
|
|
||||||
|
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
||||||
SearchTabView()
|
SearchTabView()
|
||||||
.environmentObject(searchViewModel)
|
.environmentObject(searchViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.tabItem {
|
|
||||||
Label("Search", systemImage: "magnifyingglass")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.search)
|
|
||||||
|
|
||||||
|
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) {
|
||||||
GroceryListTabView()
|
GroceryListTabView()
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.tabItem {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Label("Grocery List", systemImage: "storefront")
|
|
||||||
} else {
|
|
||||||
Label("Grocery List", systemImage: "heart.text.square")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tag(Tab.groceryList)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
}
|
.modifier(TabBarMinimizeModifier())
|
||||||
.task {
|
.task {
|
||||||
recipeViewModel.presentLoadingIndicator = true
|
recipeViewModel.presentLoadingIndicator = true
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@@ -148,7 +149,7 @@ struct BorderedLoginTextField: View {
|
|||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.accentColor(color)
|
.tint(color)
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
@@ -170,7 +171,7 @@ struct LoginTextField: View {
|
|||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.accentColor(color)
|
.tint(color)
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
@@ -203,7 +204,7 @@ struct ServerAddressField: View {
|
|||||||
.tint(.white)
|
.tint(.white)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.onChange(of: serverProtocol) { value in
|
.onChange(of: serverProtocol) { value in
|
||||||
print(value)
|
Logger.view.debug("\(value.rawValue)")
|
||||||
userSettings.serverProtocol = value.rawValue
|
userSettings.serverProtocol = value.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ struct TokenLoginView: View {
|
|||||||
case .username:
|
case .username:
|
||||||
focusedField = .token
|
focusedField = .token
|
||||||
default:
|
default:
|
||||||
print("Attempting to log in ...")
|
Logger.view.debug("Attempting to log in ...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,19 +90,14 @@ struct TokenLoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserSettings.shared.setAuthString()
|
UserSettings.shared.setAuthString()
|
||||||
let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString)
|
let client = CookbookApiFactory.makeClient()
|
||||||
|
do {
|
||||||
if let error = error {
|
let _ = try await client.getCategories()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
alertMessage = "Login failed. Please check your inputs and internet connection."
|
alertMessage = "Login failed. Please check your inputs and internet connection."
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
|
||||||
alertMessage = "Login failed. Please check your inputs."
|
|
||||||
showAlert = true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ struct V2LoginView: View {
|
|||||||
Task {
|
Task {
|
||||||
let error = await sendLoginV2Request()
|
let error = await sendLoginV2Request()
|
||||||
if let error = error {
|
if let error = error {
|
||||||
alertMessage = "A network error occured (\(error.rawValue))."
|
alertMessage = "A network error occured (\(error.localizedDescription))."
|
||||||
showAlert = true
|
showAlert = true
|
||||||
}
|
}
|
||||||
if let loginRequest = loginRequest {
|
if let loginRequest = loginRequest {
|
||||||
@@ -151,13 +152,13 @@ struct V2LoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
|
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)
|
return await NextcloudApi.loginV2Response(req: loginRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
||||||
if let error = error {
|
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
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,7 @@ struct V2LoginView: View {
|
|||||||
showAlert = true
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
print("Login successful for user \(response.loginName)!")
|
Logger.network.debug("Login successful for user \(response.loginName)!")
|
||||||
UserSettings.shared.username = response.loginName
|
UserSettings.shared.username = response.loginName
|
||||||
UserSettings.shared.token = response.appPassword
|
UserSettings.shared.token = response.appPassword
|
||||||
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||||
@@ -187,12 +188,17 @@ struct WebViewSheet: View {
|
|||||||
@State var url: String
|
@State var url: String
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
WebView(url: URL(string: url)!)
|
WebView(url: URL(string: url)!)
|
||||||
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline)
|
.navigationTitle("Nextcloud Login")
|
||||||
.navigationBarItems(trailing: Button("Done") {
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct RecipeListView: View {
|
struct RecipeListView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@@ -64,7 +65,6 @@ struct RecipeListView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
print("Add new recipe")
|
|
||||||
showEditView = true
|
showEditView = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
@@ -291,23 +292,18 @@ extension RecipeView {
|
|||||||
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
|
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
|
||||||
if let scrapedRecipe = scrapedRecipe {
|
if let scrapedRecipe = scrapedRecipe {
|
||||||
viewModel.setupView(recipeDetail: scrapedRecipe)
|
viewModel.setupView(recipeDetail: scrapedRecipe)
|
||||||
|
// Fetch the image from the server if the import created a recipe with a valid id
|
||||||
|
if let recipeId = Int(scrapedRecipe.id), recipeId > 0 {
|
||||||
|
viewModel.recipeImage = await appState.getImage(
|
||||||
|
id: recipeId,
|
||||||
|
size: .FULL,
|
||||||
|
fetchMode: .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
|
||||||
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
|
|
||||||
if let scrapedRecipe = scrapedRecipe {
|
|
||||||
viewModel.setupView(recipeDetail: scrapedRecipe)
|
|
||||||
}
|
|
||||||
if let error = error {
|
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
print("Error")
|
|
||||||
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -371,7 +367,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
print("Sharing recipe ...")
|
Logger.view.debug("Sharing recipe ...")
|
||||||
viewModel.presentShareSheet = true
|
viewModel.presentShareSheet = true
|
||||||
} label: {
|
} label: {
|
||||||
Label("Share Recipe", systemImage: "square.and.arrow.up")
|
Label("Share Recipe", systemImage: "square.and.arrow.up")
|
||||||
@@ -386,34 +382,70 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
|
|
||||||
func handleUpload() async {
|
func handleUpload() async {
|
||||||
if viewModel.newRecipe {
|
if viewModel.newRecipe {
|
||||||
print("Uploading new recipe.")
|
// Check if the recipe was already created on the server by import
|
||||||
|
let importedId = Int(viewModel.observableRecipeDetail.id) ?? 0
|
||||||
|
let alreadyCreatedByImport = importedId > 0
|
||||||
|
|
||||||
|
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() {
|
if let recipeValidationError = recipeValid() {
|
||||||
viewModel.presentAlert(recipeValidationError)
|
viewModel.presentAlert(recipeValidationError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) {
|
let (newId, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true)
|
||||||
|
if let alert {
|
||||||
viewModel.presentAlert(alert)
|
viewModel.presentAlert(alert)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let newId {
|
||||||
|
viewModel.observableRecipeDetail.id = String(newId)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
print("Uploading changed recipe.")
|
Logger.view.debug("Uploading changed recipe.")
|
||||||
|
|
||||||
guard let _ = Int(viewModel.observableRecipeDetail.id) else {
|
guard let _ = Int(viewModel.observableRecipeDetail.id) else {
|
||||||
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
|
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
viewModel.presentAlert(alert)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await appState.getCategories()
|
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) {
|
if let id = Int(viewModel.observableRecipeDetail.id) {
|
||||||
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
|
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
|
||||||
|
// Fetch the image after upload so it displays in view mode
|
||||||
|
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
|
||||||
|
// Update recipe reference so the view tracks the server-assigned id
|
||||||
|
viewModel.recipe = Recipe(
|
||||||
|
name: viewModel.observableRecipeDetail.name,
|
||||||
|
keywords: viewModel.observableRecipeDetail.keywords.joined(separator: ","),
|
||||||
|
dateCreated: "",
|
||||||
|
dateModified: "",
|
||||||
|
imageUrl: viewModel.observableRecipeDetail.imageUrl,
|
||||||
|
imagePlaceholderUrl: "",
|
||||||
|
recipe_id: id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
viewModel.newRecipe = false
|
||||||
viewModel.editMode = false
|
viewModel.editMode = false
|
||||||
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
|
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct EditableListView: View {
|
|||||||
@State var axis: Axis = .vertical
|
@State var axis: Axis = .vertical
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
List {
|
List {
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
@@ -101,12 +101,15 @@ struct EditableListView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitle(title, displayMode: .inline)
|
.navigationTitle(title)
|
||||||
.navigationBarItems(
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
trailing: Button(action: { isPresented = false }) {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(action: { isPresented = false }) {
|
||||||
Text("Done")
|
Text("Done")
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
.environment(\.editMode, .constant(.active))
|
.environment(\.editMode, .constant(.active))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ struct RecipeIngredientSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Image(systemName: "storefront")
|
Image(systemName: "storefront")
|
||||||
} else {
|
|
||||||
Image(systemName: "heart.text.square")
|
|
||||||
}
|
|
||||||
}.disabled(viewModel.editMode)
|
}.disabled(viewModel.editMode)
|
||||||
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
@@ -111,14 +107,8 @@ fileprivate struct IngredientListItem: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Image(systemName: "storefront")
|
Image(systemName: "storefront")
|
||||||
.foregroundStyle(Color.green)
|
.foregroundStyle(Color.green)
|
||||||
} else {
|
|
||||||
Image(systemName: "heart.text.square")
|
|
||||||
.foregroundStyle(Color.green)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if isSelected {
|
} else if isSelected {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
@@ -55,10 +56,15 @@ struct TimerView: View {
|
|||||||
.bold()
|
.bold()
|
||||||
.padding()
|
.padding()
|
||||||
.background {
|
.background {
|
||||||
|
if #available(iOS 26, *) {
|
||||||
|
Color.clear
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||||
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 20)
|
RoundedRectangle(cornerRadius: 20)
|
||||||
.foregroundStyle(.ultraThickMaterial)
|
.foregroundStyle(.ultraThickMaterial)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,7 +153,7 @@ extension RecipeTimer {
|
|||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
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?.prepareToPlay()
|
||||||
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
||||||
} catch {
|
} catch {
|
||||||
print("Error loading sound file: \(error)")
|
Logger.view.error("Error loading sound file: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,9 +186,9 @@ extension RecipeTimer {
|
|||||||
func requestNotificationPermissions() {
|
func requestNotificationPermissions() {
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
print("Notification permission granted.")
|
Logger.view.debug("Notification permission granted.")
|
||||||
} else if let error = error {
|
} 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
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
if let error = error {
|
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 Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Log out") {
|
Button("Log out") {
|
||||||
print("Log out.")
|
Logger.view.debug("Log out.")
|
||||||
viewModel.alertType = .LOG_OUT
|
viewModel.alertType = .LOG_OUT
|
||||||
viewModel.showAlert = true
|
viewModel.showAlert = true
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ struct SettingsView: View {
|
|||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
|
||||||
Button("Delete local data") {
|
Button("Delete local data") {
|
||||||
print("Clear cache.")
|
Logger.view.debug("Clear cache.")
|
||||||
viewModel.alertType = .DELETE_CACHE
|
viewModel.alertType = .DELETE_CACHE
|
||||||
viewModel.showAlert = true
|
viewModel.showAlert = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ class GroceryRecipeItem: Identifiable, Codable {
|
|||||||
|
|
||||||
|
|
||||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
|
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 {
|
DispatchQueue.main.async {
|
||||||
if self.groceryDict[recipeId] != nil {
|
if self.groceryDict[recipeId] != nil {
|
||||||
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
||||||
@@ -174,7 +175,7 @@ class GroceryRecipeItem: Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
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 recipe = groceryDict[recipeId] else { return }
|
||||||
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
||||||
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
||||||
@@ -186,20 +187,20 @@ class GroceryRecipeItem: Identifiable, Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteGroceryRecipe(_ recipeId: String) {
|
func deleteGroceryRecipe(_ recipeId: String) {
|
||||||
print("Deleting grocery recipe with id \(recipeId)")
|
Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
|
||||||
groceryDict.removeValue(forKey: recipeId)
|
groceryDict.removeValue(forKey: recipeId)
|
||||||
save()
|
save()
|
||||||
objectWillChange.send()
|
objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAll() {
|
func deleteAll() {
|
||||||
print("Deleting all grocery items")
|
Logger.view.debug("Deleting all grocery items")
|
||||||
groceryDict = [:]
|
groceryDict = [:]
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
||||||
print("Item checked: \(groceryItem.name)")
|
Logger.view.debug("Item checked: \(groceryItem.name)")
|
||||||
groceryItem.isChecked.toggle()
|
groceryItem.isChecked.toggle()
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
@@ -229,7 +230,7 @@ class GroceryRecipeItem: Identifiable, Codable {
|
|||||||
) else { return }
|
) else { return }
|
||||||
self.groceryDict = groceryDict
|
self.groceryDict = groceryDict
|
||||||
} catch {
|
} catch {
|
||||||
print("Unable to load grocery list")
|
Logger.view.error("Unable to load grocery list")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
// Server connection indicator
|
// Server connection indicator
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
print("Check server connection")
|
Logger.view.debug("Check server connection")
|
||||||
viewModel.presentConnectionPopover = true
|
viewModel.presentConnectionPopover = true
|
||||||
} label: {
|
} label: {
|
||||||
if viewModel.presentLoadingIndicator {
|
if viewModel.presentLoadingIndicator {
|
||||||
@@ -170,7 +171,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
// Create new recipes
|
// Create new recipes
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
print("Add new recipe")
|
Logger.view.debug("Add new recipe")
|
||||||
viewModel.presentEditView = true
|
viewModel.presentEditView = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
|
|||||||
Reference in New Issue
Block a user