Compare commits
8 Commits
1.10.1
...
c8ddb098d1
| Author | SHA1 | Date | |
|---|---|---|---|
| c8ddb098d1 | |||
| 7c824b492e | |||
| 527acd2967 | |||
|
|
512d534edf | ||
|
|
c4be0e98b9 | ||
|
|
b4b6afb45a | ||
|
|
d6cfa6b01d | ||
|
|
498ed0d8ff |
114
CLAUDE.md
Normal file
114
CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Nextcloud Cookbook iOS Client — a native iOS/iPadOS/macOS (Mac Catalyst) client for the [Nextcloud Cookbook](https://github.com/nextcloud/cookbook) server application. Built entirely in Swift and SwiftUI. Licensed under GPLv3.
|
||||||
|
|
||||||
|
**This is not a standalone app.** It requires a Nextcloud server with the Cookbook app installed.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
This is an Xcode project (no workspace, no CocoaPods). Open `Nextcloud Cookbook iOS Client.xcodeproj` in Xcode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build from command line
|
||||||
|
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
||||||
|
-scheme "Nextcloud Cookbook iOS Client" \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
build
|
||||||
|
|
||||||
|
# Run tests (note: tests are currently boilerplate stubs with no real coverage)
|
||||||
|
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
||||||
|
-scheme "Nextcloud Cookbook iOS Client" \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Deployment target**: iOS 16.4
|
||||||
|
- **Swift version**: 5.0
|
||||||
|
- **Targets**: iPhone and iPad, Mac Catalyst enabled
|
||||||
|
|
||||||
|
## Dependencies (SPM)
|
||||||
|
|
||||||
|
Only two third-party packages, managed via Swift Package Manager integrated in the Xcode project:
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| [SwiftSoup](https://github.com/scinfu/SwiftSoup.git) (2.6.1) | HTML parsing for client-side recipe scraping |
|
||||||
|
| [TPPDF](https://github.com/techprimate/TPPDF.git) (2.4.1) | PDF generation for recipe export |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Central State Pattern
|
||||||
|
|
||||||
|
The app uses a **centralized `AppState` ObservableObject** (`AppState.swift`) as the primary ViewModel, injected via `.environmentObject()` into the SwiftUI view hierarchy. `AppState` owns:
|
||||||
|
- All data (categories, recipes, recipe details, images, timers)
|
||||||
|
- All CRUD operations against the Cookbook API
|
||||||
|
- A `FetchMode` enum (`preferLocal`, `preferServer`, `onlyLocal`, `onlyServer`) that governs the data-fetching strategy (server-first vs local-first)
|
||||||
|
- Local persistence via `DataStore`
|
||||||
|
|
||||||
|
Additional ViewModels exist as nested classes within their views (`RecipeTabView.ViewModel`, `SearchTabView.ViewModel`) or as standalone classes (`RecipeEditViewModel`, `GroceryList`).
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SwiftUI Views
|
||||||
|
├── @EnvironmentObject appState: AppState
|
||||||
|
├── @EnvironmentObject groceryList: GroceryList
|
||||||
|
└── Per-view @StateObject ViewModels
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AppState
|
||||||
|
├── cookbookApi (CookbookApiV1 — static methods) → ApiRequest → URLSession
|
||||||
|
├── DataStore (file-based JSON persistence in Documents directory)
|
||||||
|
└── UserSettings.shared (UserDefaults singleton)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Layer
|
||||||
|
|
||||||
|
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
|
||||||
|
- The global `cookbookApi` constant (`CookbookApi.swift:14`) resolves the API version at launch.
|
||||||
|
- `ApiRequest` is a generic HTTP request builder using `URLSession.shared.data(for:)` with HTTP Basic Auth.
|
||||||
|
- `NextcloudApi` handles Nextcloud-specific auth (Login Flow v2 and token-based login).
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
- **`DataStore`**: File-based persistence using `JSONEncoder`/`JSONDecoder` writing to the app's Documents directory. No Core Data or SQLite.
|
||||||
|
- **`UserSettings`**: Singleton wrapping `UserDefaults` for all user preferences and credentials.
|
||||||
|
- **Image caching**: Two-tier — in-memory dictionary in `AppState` + on-disk base64-encoded PNG files via `DataStore`.
|
||||||
|
|
||||||
|
### Key Source Directories
|
||||||
|
|
||||||
|
```
|
||||||
|
Nextcloud Cookbook iOS Client/
|
||||||
|
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings
|
||||||
|
├── Models/ # RecipeEditViewModel
|
||||||
|
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
||||||
|
├── Views/
|
||||||
|
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab)
|
||||||
|
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
|
||||||
|
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
|
||||||
|
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
|
||||||
|
│ └── ReusableViews/
|
||||||
|
├── Extensions/ # Color, Date, JSONCoder, Logger extensions
|
||||||
|
├── Util/ # Alerts, DurationComponents (ISO 8601 PT parser), JsonAny, NumberFormatter
|
||||||
|
├── RecipeExport/ # PDF, text, JSON export via RecipeExporter
|
||||||
|
└── RecipeImport/ # HTML scraping via SwiftSoup (schema.org ld+json Recipe data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Four languages supported via `Localizable.xcstrings`: English, German, Spanish, French. Spanish and French are mostly machine-translated.
|
||||||
|
|
||||||
|
## Notable Design Decisions
|
||||||
|
|
||||||
|
- The `cookbookApi` global is resolved once at launch based on `UserSettings.shared.cookbookApiVersion` and uses static protocol methods, which makes dependency injection and unit testing difficult.
|
||||||
|
- Server credentials (username, token, authString) are stored in `UserDefaults` via `UserSettings`, not in Keychain.
|
||||||
|
- No `.gitignore` file exists in the repository.
|
||||||
|
- No CI/CD, no linting tools, and no meaningful test coverage.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- Do not run `xcodebuild` directly. Ask the user to build manually in Xcode and report the results back.
|
||||||
@@ -20,19 +20,18 @@
|
|||||||
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
||||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||||
|
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
||||||
|
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */; };
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
||||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
||||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
|
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
|
||||||
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
|
|
||||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
|
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
|
||||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
|
||||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
||||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
|
|
||||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
||||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
||||||
@@ -40,6 +39,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 */; };
|
||||||
@@ -103,18 +103,18 @@
|
|||||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
|
||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE012CF0000100000001 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.swift; sourceTree = "<group>"; };
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
||||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
||||||
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
|
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
|
||||||
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
||||||
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; };
|
|
||||||
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
|
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
|
||||||
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
||||||
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
|
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
|
||||||
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
||||||
@@ -122,6 +122,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 +156,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 +261,6 @@
|
|||||||
A70171B72AB2445700064C43 /* Models */ = {
|
A70171B72AB2445700064C43 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -312,7 +311,6 @@
|
|||||||
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
|
|
||||||
);
|
);
|
||||||
path = RecipeImport;
|
path = RecipeImport;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -387,6 +385,8 @@
|
|||||||
children = (
|
children = (
|
||||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
|
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
||||||
|
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||||
A97506112B920D8100E86029 /* RecipeViewSections */,
|
A97506112B920D8100E86029 /* RecipeViewSections */,
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||||
@@ -401,6 +401,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 +439,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 +517,6 @@
|
|||||||
);
|
);
|
||||||
mainGroup = A70171752AA8E71900064C43;
|
mainGroup = A70171752AA8E71900064C43;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
|
||||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
||||||
);
|
);
|
||||||
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
||||||
@@ -572,7 +571,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 +585,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 */,
|
||||||
@@ -602,6 +602,8 @@
|
|||||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
|
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
|
||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||||
|
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
||||||
|
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||||
@@ -609,7 +611,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 +794,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 +838,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 +864,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 +888,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 +911,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 +934,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 +990,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 +1001,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
|
||||||
|
|
||||||
@@ -15,16 +16,20 @@ import UIKit
|
|||||||
@Published var recipes: [String: [Recipe]] = [:]
|
@Published var recipes: [String: [Recipe]] = [:]
|
||||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
||||||
@Published var timers: [String: RecipeTimer] = [:]
|
@Published var timers: [String: RecipeTimer] = [:]
|
||||||
|
@Published var categoryImages: [String: UIImage] = [:]
|
||||||
|
@Published var recentRecipes: [Recipe] = []
|
||||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||||
var lastUpdates: [String: Date] = [:]
|
var lastUpdates: [String: Date] = [:]
|
||||||
var allKeywords: [RecipeKeyword] = []
|
var allKeywords: [RecipeKeyword] = []
|
||||||
|
|
||||||
private let dataStore: DataStore
|
private let dataStore: DataStore
|
||||||
|
private let api: CookbookApiProtocol
|
||||||
|
|
||||||
init() {
|
init(api: CookbookApiProtocol? = nil) {
|
||||||
print("Created MainViewModel")
|
Logger.network.debug("Created AppState")
|
||||||
self.dataStore = DataStore()
|
self.dataStore = DataStore()
|
||||||
|
self.api = api ?? CookbookApiFactory.makeClient()
|
||||||
|
|
||||||
if UserSettings.shared.authString == "" {
|
if UserSettings.shared.authString == "" {
|
||||||
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||||
@@ -38,54 +43,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 +78,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 +105,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 +120,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 +131,11 @@ import UIKit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
Asynchronously retrieves all recipes either from the server or the locally cached data.
|
|
||||||
|
|
||||||
This function attempts to fetch all recipes from the server using the provided `api`. If the server connection is successful, it returns the fetched recipes. If the server connection fails, it falls back to combining locally cached recipes from different categories.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance, and categories have been previously loaded.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```swift
|
|
||||||
let recipes = await mainViewModel.getRecipes()
|
|
||||||
*/
|
|
||||||
func getRecipes() async -> [Recipe] {
|
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 +143,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 +153,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 +181,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 +201,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 +270,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 +306,49 @@ import UIKit
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Category images
|
||||||
|
|
||||||
|
func getCategoryImage(for categoryName: String) async {
|
||||||
|
guard categoryImages[categoryName] == nil else { return }
|
||||||
|
// Ensure recipes for this category are loaded
|
||||||
|
if self.recipes[categoryName] == nil || self.recipes[categoryName]!.isEmpty {
|
||||||
|
await getCategory(named: categoryName, fetchMode: .preferLocal)
|
||||||
|
}
|
||||||
|
guard let recipes = self.recipes[categoryName], !recipes.isEmpty else { return }
|
||||||
|
for recipe in recipes {
|
||||||
|
if let image = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) {
|
||||||
|
self.categoryImages[categoryName] = image
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent recipes
|
||||||
|
|
||||||
|
func addToRecentRecipes(_ recipe: Recipe) {
|
||||||
|
recentRecipes.removeAll { $0.recipe_id == recipe.recipe_id }
|
||||||
|
recentRecipes.insert(recipe, at: 0)
|
||||||
|
if recentRecipes.count > 10 {
|
||||||
|
recentRecipes = Array(recentRecipes.prefix(10))
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecentRecipes() async {
|
||||||
|
if let loaded: [Recipe] = await loadLocal(path: "recent_recipes.data") {
|
||||||
|
self.recentRecipes = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearRecentRecipes() {
|
||||||
|
recentRecipes = []
|
||||||
|
dataStore.delete(path: "recent_recipes.data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Data management
|
||||||
|
|
||||||
func deleteAllData() {
|
func deleteAllData() {
|
||||||
if dataStore.clearAll() {
|
if dataStore.clearAll() {
|
||||||
self.categories = []
|
self.categories = []
|
||||||
@@ -410,30 +359,13 @@ import UIKit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
Asynchronously deletes a recipe with the specified ID from the server and local storage.
|
|
||||||
|
|
||||||
This function attempts to delete a recipe with the specified `id` from the server using the provided `api`. If the server connection is successful, it proceeds to delete the local copy of the recipe and its details. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
|
|
||||||
|
|
||||||
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
|
||||||
|
|
||||||
- Parameters:
|
|
||||||
- id: The identifier of the recipe to delete.
|
|
||||||
- categoryName: The name of the category to which the recipe belongs.
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
```swift
|
|
||||||
let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts")
|
|
||||||
*/
|
|
||||||
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
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 +377,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 +438,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 +480,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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,6 +284,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld recipes" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "%lld Rezepte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"%lld Serving(s)" : {
|
"%lld Serving(s)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -401,7 +411,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : ""
|
"value" : "Ein einfach zu verwendender PDF-Ersteller für Swift. Wird zum Erzeugen von Rezept-PDF-Dokumenten verwendet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -467,7 +477,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : ""
|
"value" : "Aktion verzögert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -578,7 +588,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : ""
|
"value" : "Eine HTML-Parsing- und Web-Scraping-Bibliothek für Swift. Wird zum Importieren von schema.org-Rezepten von Webseiten verwendet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -663,6 +673,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Bad URL" : {
|
"Bad URL" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -752,6 +763,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Categories" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Kategorien"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Category" : {
|
"Category" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -842,6 +863,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Clear" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Löschen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Comma (e.g. 1,42)" : {
|
"Comma (e.g. 1,42)" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -931,6 +962,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Connection error" : {
|
"Connection error" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -975,6 +1007,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Cookbooks" : {
|
"Cookbooks" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1327,6 +1360,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Downloaded" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Heruntergeladen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Downloads" : {
|
"Downloads" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -1438,6 +1482,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Enter a recipe name or keyword to get started." : {
|
||||||
|
"comment" : "A description under the magnifying glass icon in the \"Search for recipes\" view, encouraging the user to start searching.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Gib einen Rezeptnamen oder ein Stichwort ein, um loszulegen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Error" : {
|
"Error" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2457,6 +2513,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No cookbooks found" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Keine Kategorien gefunden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"No keywords." : {
|
"No keywords." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2501,6 +2567,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No recipes in this cookbook" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Keine Rezepte in dieser Kategorie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"No results found" : {
|
||||||
|
"comment" : "A message indicating that no recipes were found for the current search query.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Keine Ergebnisse gefunden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"None" : {
|
"None" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2633,6 +2721,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"On server" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Auf dem Server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Other" : {
|
"Other" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2656,6 +2755,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Parsing error" : {
|
"Parsing error" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2722,6 +2822,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Please check the entered URL." : {
|
"Please check the entered URL." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2877,6 +2978,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Pull to refresh or check your server connection." : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Zum Aktualisieren nach unten ziehen oder Serververbindung prüfen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Recent searches" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Letzte Suchen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Recently Viewed" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Zuletzt angesehen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Recipe" : {
|
"Recipe" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2965,6 +3096,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Recipes will appear here once they are added to this category." : {
|
||||||
|
"comment" : "A description of what will happen when a user adds a recipe to a category.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rezepte werden hier angezeigt, sobald sie dieser Kategorie hinzugefügt werden."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Refresh" : {
|
"Refresh" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3076,6 +3219,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Search for recipes" : {
|
||||||
|
"comment" : "A prompt displayed when the search text is empty, encouraging the user to enter a search term.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rezepte suchen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Search recipe" : {
|
"Search recipe" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3120,6 +3275,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Search Results" : {
|
||||||
|
"comment" : "The title of the view that lists search results for recipes.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Suchergebnisse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Select a default cookbook" : {
|
"Select a default cookbook" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3570,7 +3737,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : ""
|
"value" : "SwiftSoup"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -3677,6 +3844,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"There are no recipes in this cookbook!" : {
|
"There are no recipes in this cookbook!" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3788,6 +3956,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue." : {
|
"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" : {
|
||||||
@@ -3926,7 +4095,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : ""
|
"value" : "TPPDF"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -3966,6 +4135,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Try a different search term." : {
|
||||||
|
"comment" : "A message suggesting a different search term if no results are found.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Versuche einen anderen Suchbegriff."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Unable to complete action." : {
|
"Unable to complete action." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -4011,6 +4192,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" : {
|
||||||
@@ -4299,5 +4481,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.1"
|
||||||
}
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
//
|
|
||||||
// RecipeEditViewModel.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 11.11.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor class RecipeEditViewModel: ObservableObject {
|
|
||||||
@ObservedObject var mainViewModel: AppState
|
|
||||||
@Published var recipe: RecipeDetail = RecipeDetail()
|
|
||||||
|
|
||||||
@Published var prepDuration: DurationComponents = DurationComponents()
|
|
||||||
@Published var cookDuration: DurationComponents = DurationComponents()
|
|
||||||
@Published var totalDuration: DurationComponents = DurationComponents()
|
|
||||||
|
|
||||||
@Published var searchText: String = ""
|
|
||||||
@Published var keywords: [String] = []
|
|
||||||
@Published var keywordSuggestions: [RecipeKeyword] = []
|
|
||||||
|
|
||||||
@Published var showImportSection: Bool = false
|
|
||||||
@Published var importURL: String = ""
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
var uploadNew: Bool = true
|
|
||||||
var waitingForUpload: Bool = false
|
|
||||||
|
|
||||||
|
|
||||||
init(mainViewModel: AppState, uploadNew: Bool) {
|
|
||||||
self.mainViewModel = mainViewModel
|
|
||||||
self.uploadNew = uploadNew
|
|
||||||
}
|
|
||||||
|
|
||||||
init(mainViewModel: AppState, recipeDetail: RecipeDetail, uploadNew: Bool) {
|
|
||||||
self.mainViewModel = mainViewModel
|
|
||||||
self.recipe = recipeDetail
|
|
||||||
self.uploadNew = uploadNew
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func createRecipe() {
|
|
||||||
self.recipe.prepTime = prepDuration.toPTString()
|
|
||||||
self.recipe.cookTime = cookDuration.toPTString()
|
|
||||||
self.recipe.totalTime = totalDuration.toPTString()
|
|
||||||
self.recipe.setKeywordsFromArray(keywords)
|
|
||||||
}
|
|
||||||
|
|
||||||
func recipeValid() -> RecipeAlert? {
|
|
||||||
// Check if the recipe has a name
|
|
||||||
if recipe.name.replacingOccurrences(of: " ", with: "") == "" {
|
|
||||||
return RecipeAlert.NO_TITLE
|
|
||||||
}
|
|
||||||
// Check if the recipe has a unique name
|
|
||||||
for recipeList in mainViewModel.recipes.values {
|
|
||||||
for r in recipeList {
|
|
||||||
if r.name
|
|
||||||
.replacingOccurrences(of: " ", with: "")
|
|
||||||
.lowercased() ==
|
|
||||||
recipe.name
|
|
||||||
.replacingOccurrences(of: " ", with: "")
|
|
||||||
.lowercased()
|
|
||||||
{
|
|
||||||
return RecipeAlert.DUPLICATE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadNewRecipe() async -> UserAlert? {
|
|
||||||
print("Uploading new recipe.")
|
|
||||||
waitingForUpload = true
|
|
||||||
createRecipe()
|
|
||||||
if let recipeValidationError = recipeValid() {
|
|
||||||
return recipeValidationError
|
|
||||||
}
|
|
||||||
|
|
||||||
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func uploadEditedRecipe() async -> UserAlert? {
|
|
||||||
waitingForUpload = true
|
|
||||||
print("Uploading changed recipe.")
|
|
||||||
guard let recipeId = Int(recipe.id) else { return RequestAlert.REQUEST_DROPPED }
|
|
||||||
createRecipe()
|
|
||||||
|
|
||||||
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteRecipe() async -> RequestAlert? {
|
|
||||||
guard let id = Int(recipe.id) else {
|
|
||||||
return .REQUEST_DROPPED
|
|
||||||
}
|
|
||||||
return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory)
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareView() {
|
|
||||||
if let prepTime = recipe.prepTime {
|
|
||||||
prepDuration.fromPTString(prepTime)
|
|
||||||
}
|
|
||||||
if let cookTime = recipe.cookTime {
|
|
||||||
cookDuration.fromPTString(cookTime)
|
|
||||||
}
|
|
||||||
if let totalTime = recipe.totalTime {
|
|
||||||
totalDuration.fromPTString(totalTime)
|
|
||||||
}
|
|
||||||
self.keywords = recipe.getKeywordsArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
func importRecipe() async -> UserAlert? {
|
|
||||||
let (scrapedRecipe, error) = await mainViewModel.importRecipe(url: importURL)
|
|
||||||
if let scrapedRecipe = scrapedRecipe {
|
|
||||||
self.recipe = scrapedRecipe
|
|
||||||
prepareView()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL)
|
|
||||||
if let scrapedRecipe = scrapedRecipe {
|
|
||||||
self.recipe = scrapedRecipe
|
|
||||||
prepareView()
|
|
||||||
}
|
|
||||||
if let error = error {
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("Error")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,10 +34,10 @@ struct ApiRequest {
|
|||||||
|
|
||||||
// Prepare URL
|
// 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,46 +15,48 @@ 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()
|
||||||
await appState.updateAllRecipeDetails()
|
await appState.updateAllRecipeDetails()
|
||||||
|
|
||||||
|
// Preload category images
|
||||||
|
for category in appState.categories {
|
||||||
|
await appState.getCategoryImage(for: category.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recently viewed recipes
|
||||||
|
await appState.loadRecentRecipes()
|
||||||
|
|
||||||
// Open detail view for default category
|
// Open detail view for default category
|
||||||
if UserSettings.shared.defaultCategory != "" {
|
if UserSettings.shared.defaultCategory != "" {
|
||||||
if let cat = appState.categories.first(where: { c in
|
if let cat = appState.categories.first(where: { c in
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//
|
||||||
|
// CategoryCardView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryCardView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
let category: Category
|
||||||
|
var isSelected: Bool = false
|
||||||
|
|
||||||
|
@State private var imageLoaded = false
|
||||||
|
|
||||||
|
private var displayName: String {
|
||||||
|
category.name == "*" ? String(localized: "Other") : category.name
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
// Background image or gradient fallback
|
||||||
|
if let image = appState.categoryImages[category.name] {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
||||||
|
.clipped()
|
||||||
|
.opacity(imageLoaded ? 1 : 0)
|
||||||
|
.animation(.easeIn(duration: 0.3), value: imageLoaded)
|
||||||
|
.onAppear { imageLoaded = true }
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
||||||
|
.overlay(alignment: .center) {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom scrim with text
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Spacer()
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.6)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 60)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(displayName)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(category.recipe_count) recipes")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 17)
|
||||||
|
.stroke(isSelected ? Color.nextcloudBlue : .clear, lineWidth: 3)
|
||||||
|
)
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
||||||
|
.task {
|
||||||
|
if appState.categoryImages[category.name] == nil {
|
||||||
|
await appState.getCategoryImage(for: category.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
//
|
||||||
|
// RecentRecipesSection.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RecentRecipesSection: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Recently Viewed")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
appState.clearRecentRecipes()
|
||||||
|
} label: {
|
||||||
|
Text("Clear")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(spacing: 12) {
|
||||||
|
ForEach(appState.recentRecipes) { recipe in
|
||||||
|
NavigationLink(value: recipe) {
|
||||||
|
RecentRecipeCard(recipe: recipe)
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RecentRecipeCard: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
let recipe: Recipe
|
||||||
|
@State private var thumbnail: UIImage?
|
||||||
|
|
||||||
|
private var keywordsText: String? {
|
||||||
|
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
|
||||||
|
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items.prefix(3).joined(separator: " \u{00B7} ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Thumbnail
|
||||||
|
if let thumbnail {
|
||||||
|
Image(uiImage: thumbnail)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 160, height: 120)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 160, height: 120)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text content
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(recipe.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
if let keywordsText {
|
||||||
|
Text(keywordsText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
.frame(width: 160)
|
||||||
|
.background(Color.backgroundHighlight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||||
|
.task {
|
||||||
|
thumbnail = await appState.getImage(
|
||||||
|
id: recipe.recipe_id,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,52 +12,64 @@ struct RecipeCardView: View {
|
|||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@State var recipe: Recipe
|
@State var recipe: Recipe
|
||||||
@State var recipeThumb: UIImage?
|
@State var recipeThumb: UIImage?
|
||||||
@State var isDownloaded: Bool? = nil
|
|
||||||
|
private var keywordsText: String? {
|
||||||
|
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
|
||||||
|
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items.prefix(3).joined(separator: " \u{00B7} ")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Thumbnail
|
||||||
if let recipeThumb = recipeThumb {
|
if let recipeThumb = recipeThumb {
|
||||||
Image(uiImage: recipeThumb)
|
Image(uiImage: recipeThumb)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: 80, height: 80)
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
.clipped()
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "square.text.square")
|
LinearGradient(
|
||||||
.resizable()
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
.aspectRatio(contentMode: .fit)
|
startPoint: .topLeading,
|
||||||
.foregroundStyle(Color.white)
|
endPoint: .bottomTrailing
|
||||||
.padding(10)
|
)
|
||||||
.background(Color("ncblue"))
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
|
||||||
.frame(width: 80, height: 80)
|
.overlay {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(recipe.name)
|
|
||||||
.font(.headline)
|
|
||||||
.padding(.leading, 4)
|
|
||||||
|
|
||||||
Spacer()
|
// Text content
|
||||||
if let isDownloaded = isDownloaded {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
VStack {
|
Text(recipe.name)
|
||||||
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
.font(.subheadline)
|
||||||
.foregroundColor(.secondary)
|
.fontWeight(.medium)
|
||||||
.padding()
|
.lineLimit(2)
|
||||||
Spacer()
|
.multilineTextAlignment(.leading)
|
||||||
|
|
||||||
|
if let keywordsText {
|
||||||
|
Text(keywordsText)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
.background(Color.backgroundHighlight)
|
.background(Color.backgroundHighlight)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||||
.task {
|
.task {
|
||||||
recipeThumb = await appState.getImage(
|
recipeThumb = await appState.getImage(
|
||||||
id: recipe.recipe_id,
|
id: recipe.recipe_id,
|
||||||
size: .THUMB,
|
size: .THUMB,
|
||||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
)
|
)
|
||||||
if recipe.storedLocally == nil {
|
|
||||||
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
|
|
||||||
}
|
|
||||||
isDownloaded = recipe.storedLocally
|
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
recipeThumb = await appState.getImage(
|
recipeThumb = await appState.getImage(
|
||||||
@@ -66,6 +78,5 @@ struct RecipeCardView: View {
|
|||||||
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.frame(height: 80)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,38 +18,55 @@ struct RecipeListView: View {
|
|||||||
@Binding var showEditView: Bool
|
@Binding var showEditView: Bool
|
||||||
@State var selectedRecipe: Recipe? = nil
|
@State var selectedRecipe: Recipe? = nil
|
||||||
|
|
||||||
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
let recipes = recipesFiltered()
|
let recipes = recipesFiltered()
|
||||||
if !recipes.isEmpty {
|
if !recipes.isEmpty {
|
||||||
List(recipesFiltered(), id: \.recipe_id) { recipe in
|
ScrollView {
|
||||||
RecipeCardView(recipe: recipe)
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.shadow(radius: 2)
|
Text("\(recipes.count) recipes")
|
||||||
.background(
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||||
|
ForEach(recipes, id: \.recipe_id) { recipe in
|
||||||
NavigationLink(value: recipe) {
|
NavigationLink(value: recipe) {
|
||||||
EmptyView()
|
RecipeCardView(recipe: recipe)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.opacity(0)
|
|
||||||
)
|
|
||||||
.frame(height: 85)
|
|
||||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
|
||||||
.listRowSeparatorTint(.clear)
|
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
VStack {
|
VStack(spacing: 16) {
|
||||||
Text("There are no recipes in this cookbook!")
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No recipes in this cookbook")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Recipes will appear here once they are added to this category.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
await appState.getCategory(named: categoryName, fetchMode: .preferServer)
|
await appState.getCategory(named: categoryName, fetchMode: .preferServer)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Refresh")
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.nextcloudBlue)
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,7 +81,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
|
||||||
|
|
||||||
|
|
||||||
@@ -167,6 +168,9 @@ struct RecipeView: View {
|
|||||||
) ?? RecipeDetail.error
|
) ?? RecipeDetail.error
|
||||||
viewModel.setupView(recipeDetail: recipeDetail)
|
viewModel.setupView(recipeDetail: recipeDetail)
|
||||||
|
|
||||||
|
// Track as recently viewed
|
||||||
|
appState.addToRecentRecipes(viewModel.recipe)
|
||||||
|
|
||||||
// Show download badge
|
// Show download badge
|
||||||
if viewModel.recipe.storedLocally == nil {
|
if viewModel.recipe.storedLocally == nil {
|
||||||
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
|
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
|
||||||
@@ -291,23 +295,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 +370,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 +385,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
|
||||||
|
|
||||||
|
|
||||||
@@ -13,44 +14,66 @@ struct RecipeTabView: View {
|
|||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(selection: $viewModel.selectedCategory) {
|
ScrollView {
|
||||||
// Categories
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
// Recently Viewed
|
||||||
|
if !appState.recentRecipes.isEmpty {
|
||||||
|
RecentRecipesSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories header
|
||||||
|
if !appState.categories.isEmpty {
|
||||||
|
Text("Categories")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category grid
|
||||||
|
if appState.categories.isEmpty {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No cookbooks found")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Pull to refresh or check your server connection.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 40)
|
||||||
|
} else {
|
||||||
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||||
ForEach(appState.categories) { category in
|
ForEach(appState.categories) { category in
|
||||||
NavigationLink(value: category) {
|
Button {
|
||||||
HStack(alignment: .center) {
|
viewModel.selectedCategory = category
|
||||||
if viewModel.selectedCategory != nil &&
|
if horizontalSizeClass == .compact {
|
||||||
category.name == viewModel.selectedCategory!.name {
|
viewModel.navigateToCategory = true
|
||||||
Image(systemName: "book")
|
|
||||||
} else {
|
|
||||||
Image(systemName: "book.closed.fill")
|
|
||||||
}
|
}
|
||||||
|
} label: {
|
||||||
if category.name == "*" {
|
CategoryCardView(
|
||||||
Text("Other")
|
category: category,
|
||||||
.font(.system(size: 20, weight: .medium, design: .default))
|
isSelected: viewModel.selectedCategory?.name == category.name
|
||||||
} else {
|
)
|
||||||
Text(category.name)
|
|
||||||
.font(.system(size: 20, weight: .medium, design: .default))
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
Spacer()
|
|
||||||
Text("\(category.recipe_count)")
|
|
||||||
.font(.system(size: 15, weight: .bold, design: .default))
|
|
||||||
.foregroundStyle(Color.background)
|
|
||||||
.frame(width: 25, height: 25, alignment: .center)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
.background {
|
|
||||||
Circle()
|
|
||||||
.foregroundStyle(Color.secondary)
|
|
||||||
}
|
|
||||||
}.padding(7)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
.navigationTitle("Cookbooks")
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.navigationTitle("Recipes")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
RecipeTabViewToolBar()
|
RecipeTabViewToolBar()
|
||||||
}
|
}
|
||||||
@@ -63,6 +86,22 @@ struct RecipeTabView: View {
|
|||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
}
|
}
|
||||||
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $viewModel.navigateToCategory) {
|
||||||
|
if let category = viewModel.selectedCategory {
|
||||||
|
RecipeListView(
|
||||||
|
categoryName: category.name,
|
||||||
|
showEditView: $viewModel.presentEditView
|
||||||
|
)
|
||||||
|
.id(category.id)
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
}
|
||||||
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if let category = viewModel.selectedCategory {
|
if let category = viewModel.selectedCategory {
|
||||||
@@ -70,9 +109,8 @@ struct RecipeTabView: View {
|
|||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
showEditView: $viewModel.presentEditView
|
showEditView: $viewModel.presentEditView
|
||||||
)
|
)
|
||||||
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
.id(category.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.nextcloudBlue)
|
.tint(.nextcloudBlue)
|
||||||
@@ -88,12 +126,17 @@ struct RecipeTabView: View {
|
|||||||
viewModel.serverConnection = connection
|
viewModel.serverConnection = connection
|
||||||
}
|
}
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
|
for category in appState.categories {
|
||||||
|
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
|
await appState.getCategoryImage(for: category.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var presentEditView: Bool = false
|
@Published var presentEditView: Bool = false
|
||||||
@Published var presentSettingsView: Bool = false
|
@Published var presentSettingsView: Bool = false
|
||||||
|
@Published var navigateToCategory: Bool = false
|
||||||
|
|
||||||
@Published var presentLoadingIndicator: Bool = false
|
@Published var presentLoadingIndicator: Bool = false
|
||||||
@Published var presentConnectionPopover: Bool = false
|
@Published var presentConnectionPopover: Bool = false
|
||||||
@@ -143,7 +186,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 +213,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")
|
||||||
|
|||||||
@@ -14,10 +14,88 @@ struct SearchTabView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack {
|
List {
|
||||||
List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
let results = viewModel.recipesFiltered()
|
||||||
RecipeCardView(recipe: recipe)
|
|
||||||
.shadow(radius: 2)
|
if viewModel.searchText.isEmpty {
|
||||||
|
// Icon + explainer
|
||||||
|
Section {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "magnifyingglass")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Search for recipes")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Enter a recipe name or keyword to get started.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search history
|
||||||
|
if !viewModel.searchHistory.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(viewModel.searchHistory, id: \.self) { term in
|
||||||
|
Button {
|
||||||
|
viewModel.searchText = term
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
Text(term)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { offsets in
|
||||||
|
viewModel.removeHistory(at: offsets)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Recent searches")
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
viewModel.clearHistory()
|
||||||
|
} label: {
|
||||||
|
Text("Clear")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if results.isEmpty {
|
||||||
|
// No results
|
||||||
|
Section {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "magnifyingglass.circle")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No results found")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Try a different search term.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 24)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Results
|
||||||
|
Section {
|
||||||
|
ForEach(results, id: \.recipe_id) { recipe in
|
||||||
|
SearchRecipeRow(recipe: recipe)
|
||||||
.background(
|
.background(
|
||||||
NavigationLink(value: recipe) {
|
NavigationLink(value: recipe) {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
@@ -25,17 +103,21 @@ struct SearchTabView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.opacity(0)
|
.opacity(0)
|
||||||
)
|
)
|
||||||
.frame(height: 85)
|
.listRowInsets(EdgeInsets(top: 6, leading: 15, bottom: 6, trailing: 15))
|
||||||
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
|
||||||
.listRowSeparatorTint(.clear)
|
.listRowSeparatorTint(.clear)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
}
|
}
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
viewModel.saveToHistory(viewModel.searchText)
|
||||||
}
|
}
|
||||||
.navigationTitle("Search recipe")
|
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
if viewModel.allRecipes.isEmpty {
|
if viewModel.allRecipes.isEmpty {
|
||||||
@@ -51,24 +133,114 @@ struct SearchTabView: View {
|
|||||||
@Published var allRecipes: [Recipe] = []
|
@Published var allRecipes: [Recipe] = []
|
||||||
@Published var searchText: String = ""
|
@Published var searchText: String = ""
|
||||||
@Published var searchMode: SearchMode = .name
|
@Published var searchMode: SearchMode = .name
|
||||||
|
@Published var searchHistory: [String] = []
|
||||||
|
|
||||||
|
private static let historyKey = "searchHistory"
|
||||||
|
private static let maxHistory = 15
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.searchHistory = UserDefaults.standard.stringArray(forKey: Self.historyKey) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
enum SearchMode: String, CaseIterable {
|
enum SearchMode: String, CaseIterable {
|
||||||
case name = "Name & Keywords", ingredient = "Ingredients"
|
case name = "Name & Keywords", ingredient = "Ingredients"
|
||||||
}
|
}
|
||||||
|
|
||||||
func recipesFiltered() -> [Recipe] {
|
func recipesFiltered() -> [Recipe] {
|
||||||
|
guard searchText != "" else { return [] }
|
||||||
if searchMode == .name {
|
if searchMode == .name {
|
||||||
guard searchText != "" else { return allRecipes }
|
|
||||||
return allRecipes.filter { recipe in
|
return allRecipes.filter { recipe in
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
||||||
}
|
}
|
||||||
} else if searchMode == .ingredient {
|
} else if searchMode == .ingredient {
|
||||||
// TODO: Fuzzy ingredient search
|
// TODO: Fuzzy ingredient search
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func saveToHistory(_ term: String) {
|
||||||
|
let trimmed = term.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
searchHistory.removeAll { $0.lowercased() == trimmed.lowercased() }
|
||||||
|
searchHistory.insert(trimmed, at: 0)
|
||||||
|
if searchHistory.count > Self.maxHistory {
|
||||||
|
searchHistory = Array(searchHistory.prefix(Self.maxHistory))
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(searchHistory, forKey: Self.historyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeHistory(at offsets: IndexSet) {
|
||||||
|
searchHistory.remove(atOffsets: offsets)
|
||||||
|
UserDefaults.standard.set(searchHistory, forKey: Self.historyKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearHistory() {
|
||||||
|
searchHistory = []
|
||||||
|
UserDefaults.standard.removeObject(forKey: Self.historyKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Horizontal row card for search results
|
||||||
|
|
||||||
|
private struct SearchRecipeRow: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@State var recipe: Recipe
|
||||||
|
@State private var recipeThumb: UIImage?
|
||||||
|
|
||||||
|
private var keywordsText: String? {
|
||||||
|
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
|
||||||
|
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items.prefix(3).joined(separator: " \u{00B7} ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let recipeThumb {
|
||||||
|
Image(uiImage: recipeThumb)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 70, height: 70)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 70, height: 70)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(recipe.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if let keywordsText {
|
||||||
|
Text(keywordsText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
recipeThumb = await appState.getImage(
|
||||||
|
id: recipe.recipe_id,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,15 +32,17 @@ You can download the app from the AppStore:
|
|||||||
|
|
||||||
- [x] **Version 1.9**: Enhancements to recipe editing for better intuitiveness; user interface design improvements for recipe viewing.
|
- [x] **Version 1.9**: Enhancements to recipe editing for better intuitiveness; user interface design improvements for recipe viewing.
|
||||||
|
|
||||||
- [ ] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
|
- [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
|
||||||
|
|
||||||
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images.
|
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code. Update: I will continue to work on this update in January 2024.
|
||||||
|
|
||||||
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
|
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
|
||||||
|
|
||||||
|
- Allow adding custom items to the grocery list.
|
||||||
|
|
||||||
- Fuzzy search for recipe names and keywords.
|
- Fuzzy search for recipe names and keywords.
|
||||||
|
|
||||||
- In-app timer for the cook time specified in a recipe.
|
- An in-app timer for the cook time specified in a recipe.
|
||||||
|
|
||||||
- Search for recipes based on left-over ingredients.
|
- Search for recipes based on left-over ingredients.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user