Compare commits
3 Commits
ios18-mode
...
1.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48b31a7997 | ||
|
|
5acf3b9c4f | ||
|
|
29fd3c668b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/hendrik.hogertz.xcuserdatad/UserInterfaceState.xcuserstate
|
|
||||||
Nextcloud Cookbook iOS Client.xcodeproj/xcuserdata/hendrik.hogertz.xcuserdatad/xcschemes/xcschememanagement.plist
|
|
||||||
Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/hendrik.hogertz.xcuserdatad/IDEFindNavigatorScopes.plist
|
|
||||||
117
CLAUDE.md
117
CLAUDE.md
@@ -1,117 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Nextcloud Cookbook iOS Client — a native iOS/iPadOS/macOS (Mac Catalyst) client for the [Nextcloud Cookbook](https://github.com/nextcloud/cookbook) server application. Built entirely in Swift and SwiftUI. Licensed under GPLv3.
|
|
||||||
|
|
||||||
**This is not a standalone app.** It requires a Nextcloud server with the Cookbook app installed.
|
|
||||||
|
|
||||||
## Build & Run
|
|
||||||
|
|
||||||
This is an Xcode project (no workspace, no CocoaPods). Open `Nextcloud Cookbook iOS Client.xcodeproj` in Xcode.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build from command line
|
|
||||||
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
|
||||||
-scheme "Nextcloud Cookbook iOS Client" \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
||||||
build
|
|
||||||
|
|
||||||
# Run tests (note: tests are currently boilerplate stubs with no real coverage)
|
|
||||||
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
|
||||||
-scheme "Nextcloud Cookbook iOS Client" \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
|
||||||
test
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Deployment target**: iOS 18
|
|
||||||
- **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: GroceryListManager
|
|
||||||
├── @EnvironmentObject mealPlan: MealPlanManager
|
|
||||||
└── Per-view @StateObject ViewModels
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
AppState
|
|
||||||
├── cookbookApi (CookbookApiV1 — static methods) → ApiRequest → URLSession
|
|
||||||
├── DataStore (file-based JSON persistence in Documents directory)
|
|
||||||
└── UserSettings.shared (UserDefaults singleton)
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `GroceryListManager` and `MealPlanManager` use custom metadata fields (`_groceryState`, `_mealPlanAssignment`) embedded in recipe JSON on the Nextcloud Cookbook API for cross-device sync. Each has a dedicated sync manager (`GroceryStateSyncManager`, `MealPlanSyncManager`) that handles debounced push, pull reconciliation, and per-item/per-date last-writer-wins merge.
|
|
||||||
|
|
||||||
### 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 + MealPlan + GroceryList
|
|
||||||
├── Models/ # RecipeEditViewModel
|
|
||||||
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
|
||||||
├── Views/
|
|
||||||
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, MealPlanTab, 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.
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
A1B2C3D52F0A000100000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* AppearanceMode.swift */; };
|
|
||||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */; };
|
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */; };
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171832AA8E71900064C43 /* MainView.swift */; };
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171832AA8E71900064C43 /* MainView.swift */; };
|
||||||
A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A70171852AA8E71F00064C43 /* Assets.xcassets */; };
|
A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A70171852AA8E71F00064C43 /* Assets.xcassets */; };
|
||||||
@@ -22,15 +21,17 @@
|
|||||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
||||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
|
||||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
|
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
|
||||||
|
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
|
||||||
|
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
|
||||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
|
||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
|
||||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
|
||||||
|
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
|
||||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
|
||||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
|
||||||
@@ -52,36 +53,28 @@
|
|||||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
|
||||||
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
|
||||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
|
||||||
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
|
A97B4D322B80B3E900EC1A88 /* CookbookModelsV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */; };
|
||||||
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
|
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
|
||||||
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */; };
|
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */; };
|
||||||
|
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; };
|
||||||
|
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; };
|
||||||
|
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; };
|
||||||
|
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */; };
|
||||||
|
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */; };
|
||||||
|
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB0512DE911C300A4C74B /* AuthManager.swift */; };
|
||||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
|
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
|
||||||
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
|
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
|
||||||
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */; };
|
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; };
|
||||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
||||||
|
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; };
|
||||||
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
|
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
|
||||||
|
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */; };
|
||||||
|
A9E78A322BEA770600206866 /* NextcloudDataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */; };
|
||||||
|
A9E78A342BEA773900206866 /* LocalDataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A332BEA773900206866 /* LocalDataInterface.swift */; };
|
||||||
|
A9E78A372BEA839100206866 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A9E78A362BEA839100206866 /* KeychainSwift */; };
|
||||||
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
|
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
|
||||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
|
||||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */; };
|
|
||||||
B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */; };
|
|
||||||
B1C0DE082CF0000400000004 /* AllRecipesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE072CF0000400000004 /* AllRecipesListView.swift */; };
|
|
||||||
C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F0AB012D0B000100000001 /* ImportURLSheet.swift */; };
|
|
||||||
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; };
|
|
||||||
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; };
|
|
||||||
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; };
|
|
||||||
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; };
|
|
||||||
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; };
|
|
||||||
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */; };
|
|
||||||
E498A7A42F41C35500D7D7A4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
|
||||||
F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE012E0C000100000001 /* MealPlanModels.swift */; };
|
|
||||||
F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE032E0C000200000002 /* MealPlanManager.swift */; };
|
|
||||||
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; };
|
|
||||||
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; };
|
|
||||||
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */; };
|
|
||||||
G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1A0CE002F0B000100000001 /* CategorySortMode.swift */; };
|
|
||||||
G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -99,32 +92,9 @@
|
|||||||
remoteGlobalIDString = A701717D2AA8E71900064C43;
|
remoteGlobalIDString = A701717D2AA8E71900064C43;
|
||||||
remoteInfo = "Nextcloud Cookbook iOS Client";
|
remoteInfo = "Nextcloud Cookbook iOS Client";
|
||||||
};
|
};
|
||||||
E498A7A22F41C35500D7D7A4 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = A70171762AA8E71900064C43 /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = E498A7992F41C35500D7D7A4;
|
|
||||||
remoteInfo = ShareExtension;
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
|
||||||
E498A7A52F41C35500D7D7A4 /* Embed Foundation Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
E498A7A42F41C35500D7D7A4 /* ShareExtension.appex in Embed Foundation Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed Foundation Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = "<group>"; };
|
|
||||||
A1B2C3D42F0A000100000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
|
|
||||||
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nextcloud Cookbook iOS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nextcloud Cookbook iOS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientApp.swift; sourceTree = "<group>"; };
|
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientApp.swift; sourceTree = "<group>"; };
|
||||||
A70171832AA8E71900064C43 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
A70171832AA8E71900064C43 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
@@ -143,15 +113,16 @@
|
|||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
|
||||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
||||||
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
|
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
|
||||||
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
|
||||||
|
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; };
|
||||||
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
|
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
|
||||||
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
|
||||||
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
|
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
|
||||||
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
|
||||||
|
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
|
||||||
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
|
||||||
@@ -173,50 +144,31 @@
|
|||||||
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
|
||||||
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
|
||||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
|
||||||
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; };
|
A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookModelsV1.swift; sourceTree = "<group>"; };
|
||||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||||
A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = "<group>"; };
|
A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = "<group>"; };
|
||||||
|
A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; };
|
||||||
|
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; };
|
||||||
|
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; };
|
||||||
|
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVStack.swift; sourceTree = "<group>"; };
|
||||||
|
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = "<group>"; };
|
||||||
|
A9AAB0512DE911C300A4C74B /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
|
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
|
||||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
|
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
|
||||||
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = "<group>"; };
|
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; };
|
||||||
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
||||||
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; };
|
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; };
|
||||||
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
|
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
|
||||||
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
|
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
|
||||||
|
A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataInterface.swift; sourceTree = "<group>"; };
|
||||||
|
A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudDataInterface.swift; sourceTree = "<group>"; };
|
||||||
|
A9E78A332BEA773900206866 /* LocalDataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalDataInterface.swift; sourceTree = "<group>"; };
|
||||||
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
||||||
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>"; };
|
|
||||||
B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesCategoryCardView.swift; sourceTree = "<group>"; };
|
|
||||||
B1C0DE072CF0000400000004 /* AllRecipesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesListView.swift; sourceTree = "<group>"; };
|
|
||||||
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportURLSheet.swift; sourceTree = "<group>"; };
|
|
||||||
D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = "<group>"; };
|
|
||||||
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = "<group>"; };
|
|
||||||
D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; };
|
|
||||||
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateModels.swift; sourceTree = "<group>"; };
|
|
||||||
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = "<group>"; };
|
|
||||||
E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
F1A0DE012E0C000100000001 /* MealPlanModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanModels.swift; sourceTree = "<group>"; };
|
|
||||||
F1A0DE032E0C000200000002 /* MealPlanManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanManager.swift; sourceTree = "<group>"; };
|
|
||||||
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = "<group>"; };
|
|
||||||
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = "<group>"; };
|
|
||||||
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = "<group>"; };
|
|
||||||
G1A0CE002F0B000100000001 /* CategorySortMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySortMode.swift; sourceTree = "<group>"; };
|
|
||||||
G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryReorderSheet.swift; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
E498A7A82F41C35500D7D7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
|
||||||
membershipExceptions = (
|
|
||||||
Info.plist,
|
|
||||||
);
|
|
||||||
target = E498A7992F41C35500D7D7A4 /* ShareExtension */;
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
E498A79B2F41C35500D7D7A4 /* ShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E498A7A82F41C35500D7D7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = ShareExtension; sourceTree = "<group>"; };
|
A9C34A722D390E69006EEB66 /* Account */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Account; sourceTree = "<group>"; };
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -224,6 +176,8 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A9E78A372BEA839100206866 /* KeychainSwift in Frameworks */,
|
||||||
|
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -242,13 +196,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
E498A7972F41C35500D7D7A4 /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -260,7 +207,6 @@
|
|||||||
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
||||||
A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
||||||
A701719C2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
A701719C2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
||||||
E498A79B2F41C35500D7D7A4 /* ShareExtension */,
|
|
||||||
A701717F2AA8E71900064C43 /* Products */,
|
A701717F2AA8E71900064C43 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -271,7 +217,6 @@
|
|||||||
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */,
|
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */,
|
||||||
A701718F2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests.xctest */,
|
A701718F2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests.xctest */,
|
||||||
A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */,
|
A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */,
|
||||||
E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */,
|
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -279,6 +224,8 @@
|
|||||||
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
|
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9C34A722D390E69006EEB66 /* Account */,
|
||||||
|
A9E78A2E2BEA726A00206866 /* Persistence */,
|
||||||
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */,
|
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */,
|
||||||
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
|
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
|
||||||
A70171AC2AA8EF4700064C43 /* AppState.swift */,
|
A70171AC2AA8EF4700064C43 /* AppState.swift */,
|
||||||
@@ -286,7 +233,7 @@
|
|||||||
A70171BA2AB4980100064C43 /* Views */,
|
A70171BA2AB4980100064C43 /* Views */,
|
||||||
A70171B72AB2445700064C43 /* Models */,
|
A70171B72AB2445700064C43 /* Models */,
|
||||||
A97B4D332B80B51700EC1A88 /* Util */,
|
A97B4D332B80B51700EC1A88 /* Util */,
|
||||||
A70171B22AB211F000064C43 /* Network */,
|
A70171B22AB211F000064C43 /* Networking */,
|
||||||
A781E75F2AF8228100452F6F /* RecipeImport */,
|
A781E75F2AF8228100452F6F /* RecipeImport */,
|
||||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
||||||
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
||||||
@@ -323,21 +270,22 @@
|
|||||||
path = "Nextcloud Cookbook iOS ClientUITests";
|
path = "Nextcloud Cookbook iOS ClientUITests";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A70171B22AB211F000064C43 /* Network */ = {
|
A70171B22AB211F000064C43 /* Networking */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
|
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
|
||||||
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
|
|
||||||
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
|
|
||||||
A70171B32AB2122900064C43 /* NetworkUtils.swift */,
|
A70171B32AB2122900064C43 /* NetworkUtils.swift */,
|
||||||
A70171B02AB211DF00064C43 /* NetworkError.swift */,
|
A70171B02AB211DF00064C43 /* NetworkError.swift */,
|
||||||
|
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
|
||||||
|
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
|
||||||
);
|
);
|
||||||
path = Network;
|
path = Networking;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
A70171B72AB2445700064C43 /* Models */ = {
|
A70171B72AB2445700064C43 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -358,21 +306,10 @@
|
|||||||
A70171C72AB4C4A100064C43 /* Data */ = {
|
A70171C72AB4C4A100064C43 /* Data */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9AAB0512DE911C300A4C74B /* AuthManager.swift */,
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */,
|
A70171C32AB4A31200064C43 /* DataStore.swift */,
|
||||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */,
|
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */,
|
||||||
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
|
|
||||||
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
|
|
||||||
A1B2C3D42F0A000100000001 /* AppearanceMode.swift */,
|
|
||||||
D1A0CE002D0A000100000001 /* GroceryListMode.swift */,
|
|
||||||
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */,
|
|
||||||
D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
|
|
||||||
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
|
|
||||||
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
|
|
||||||
F1A0DE012E0C000100000001 /* MealPlanModels.swift */,
|
|
||||||
F1A0DE032E0C000200000002 /* MealPlanManager.swift */,
|
|
||||||
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */,
|
|
||||||
G1A0CE002F0B000100000001 /* CategorySortMode.swift */,
|
|
||||||
);
|
);
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -398,6 +335,7 @@
|
|||||||
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
A781E75F2AF8228100452F6F /* RecipeImport */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
|
||||||
);
|
);
|
||||||
path = RecipeImport;
|
path = RecipeImport;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -406,7 +344,10 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
|
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
|
||||||
|
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */,
|
||||||
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
|
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
|
||||||
|
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */,
|
||||||
|
A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */,
|
||||||
);
|
);
|
||||||
path = CookbookApi;
|
path = CookbookApi;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -448,10 +389,10 @@
|
|||||||
A977D0DC2B6002DA009783A9 /* Tabs */ = {
|
A977D0DC2B6002DA009783A9 /* Tabs */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */,
|
||||||
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
||||||
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
||||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
||||||
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */,
|
|
||||||
);
|
);
|
||||||
path = Tabs;
|
path = Tabs;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -468,22 +409,25 @@
|
|||||||
path = Util;
|
path = Util;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A98F931F2C07BA4F00E34359 /* Interfaces */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */,
|
||||||
|
A9E78A332BEA773900206866 /* LocalDataInterface.swift */,
|
||||||
|
A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */,
|
||||||
|
);
|
||||||
|
path = Interfaces;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A9C3BE502B630E3900562C79 /* Recipes */ = {
|
A9C3BE502B630E3900562C79 /* Recipes */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
|
||||||
B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */,
|
|
||||||
B1C0DE072CF0000400000004 /* AllRecipesListView.swift */,
|
|
||||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
|
||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||||
A97506112B920D8100E86029 /* RecipeViewSections */,
|
A97506112B920D8100E86029 /* RecipeViewSections */,
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
||||||
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
|
|
||||||
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */,
|
|
||||||
G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */,
|
|
||||||
);
|
);
|
||||||
path = Recipes;
|
path = Recipes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -491,10 +435,10 @@
|
|||||||
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
|
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */,
|
||||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
||||||
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */,
|
|
||||||
);
|
);
|
||||||
path = ReusableViews;
|
path = ReusableViews;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -507,6 +451,15 @@
|
|||||||
path = RecipeExport;
|
path = RecipeExport;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A9E78A2E2BEA726A00206866 /* Persistence */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A98F931D2C07B07400E34359 /* CookbookState.swift */,
|
||||||
|
A98F931F2C07BA4F00E34359 /* Interfaces */,
|
||||||
|
);
|
||||||
|
path = Persistence;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A9FA2AB42B50798800A43702 /* Resources */ = {
|
A9FA2AB42B50798800A43702 /* Resources */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -525,16 +478,19 @@
|
|||||||
A701717A2AA8E71900064C43 /* Sources */,
|
A701717A2AA8E71900064C43 /* Sources */,
|
||||||
A701717B2AA8E71900064C43 /* Frameworks */,
|
A701717B2AA8E71900064C43 /* Frameworks */,
|
||||||
A701717C2AA8E71900064C43 /* Resources */,
|
A701717C2AA8E71900064C43 /* Resources */,
|
||||||
E498A7A52F41C35500D7D7A4 /* Embed Foundation Extensions */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
E498A7A32F41C35500D7D7A4 /* PBXTargetDependency */,
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
A9C34A722D390E69006EEB66 /* Account */,
|
||||||
);
|
);
|
||||||
name = "Nextcloud Cookbook iOS Client";
|
name = "Nextcloud Cookbook iOS Client";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
|
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
|
||||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
|
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
|
||||||
|
A9E78A362BEA839100206866 /* KeychainSwift */,
|
||||||
);
|
);
|
||||||
productName = "Nextcloud Cookbook iOS Client";
|
productName = "Nextcloud Cookbook iOS Client";
|
||||||
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
|
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
|
||||||
@@ -576,28 +532,6 @@
|
|||||||
productReference = A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */;
|
productReference = A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
};
|
};
|
||||||
E498A7992F41C35500D7D7A4 /* ShareExtension */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = E498A7A92F41C35500D7D7A4 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
|
||||||
buildPhases = (
|
|
||||||
E498A7962F41C35500D7D7A4 /* Sources */,
|
|
||||||
E498A7972F41C35500D7D7A4 /* Frameworks */,
|
|
||||||
E498A7982F41C35500D7D7A4 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
fileSystemSynchronizedGroups = (
|
|
||||||
E498A79B2F41C35500D7D7A4 /* ShareExtension */,
|
|
||||||
);
|
|
||||||
name = ShareExtension;
|
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = ShareExtension;
|
|
||||||
productReference = E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */;
|
|
||||||
productType = "com.apple.product-type.app-extension";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -605,7 +539,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2620;
|
LastSwiftUpdateCheck = 1430;
|
||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
A701717D2AA8E71900064C43 = {
|
A701717D2AA8E71900064C43 = {
|
||||||
@@ -619,10 +553,6 @@
|
|||||||
CreatedOnToolsVersion = 14.3;
|
CreatedOnToolsVersion = 14.3;
|
||||||
TestTargetID = A701717D2AA8E71900064C43;
|
TestTargetID = A701717D2AA8E71900064C43;
|
||||||
};
|
};
|
||||||
E498A7992F41C35500D7D7A4 = {
|
|
||||||
CreatedOnToolsVersion = 26.2;
|
|
||||||
LastSwiftMigration = 2620;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = A70171792AA8E71900064C43 /* Build configuration list for PBXProject "Nextcloud Cookbook iOS Client" */;
|
buildConfigurationList = A70171792AA8E71900064C43 /* Build configuration list for PBXProject "Nextcloud Cookbook iOS Client" */;
|
||||||
@@ -638,7 +568,9 @@
|
|||||||
);
|
);
|
||||||
mainGroup = A70171752AA8E71900064C43;
|
mainGroup = A70171752AA8E71900064C43;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
|
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||||
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
|
||||||
|
A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */,
|
||||||
);
|
);
|
||||||
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -647,7 +579,6 @@
|
|||||||
A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
||||||
A701718E2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
A701718E2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
||||||
A70171982AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
A70171982AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
||||||
E498A7992F41C35500D7D7A4 /* ShareExtension */,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -678,13 +609,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
E498A7982F41C35500D7D7A4 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -692,16 +616,18 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */,
|
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */,
|
||||||
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
|
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
|
||||||
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
|
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
|
||||||
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
|
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
|
||||||
A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */,
|
A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */,
|
||||||
|
A9E78A322BEA770600206866 /* NextcloudDataInterface.swift in Sources */,
|
||||||
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
|
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
|
||||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
||||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
||||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
||||||
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
||||||
|
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */,
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
||||||
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
|
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
|
||||||
@@ -710,55 +636,44 @@
|
|||||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
||||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
|
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
|
||||||
|
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */,
|
||||||
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
|
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
|
||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||||
|
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */,
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
||||||
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */,
|
|
||||||
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
||||||
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
|
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */,
|
||||||
|
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */,
|
||||||
|
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */,
|
||||||
|
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */,
|
||||||
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
|
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
|
||||||
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
|
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
|
||||||
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */,
|
A97B4D322B80B3E900EC1A88 /* CookbookModelsV1.swift in Sources */,
|
||||||
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
|
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
|
|
||||||
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
|
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
|
||||||
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */,
|
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */,
|
||||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
||||||
|
A9E78A342BEA773900206866 /* LocalDataInterface.swift in Sources */,
|
||||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
|
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
|
||||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
||||||
A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */,
|
A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */,
|
||||||
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 */,
|
|
||||||
B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */,
|
|
||||||
B1C0DE082CF0000400000004 /* AllRecipesListView.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 */,
|
||||||
|
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */,
|
||||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
||||||
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
|
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
|
||||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
||||||
|
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
||||||
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
|
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
||||||
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
||||||
A1B2C3D52F0A000100000001 /* AppearanceMode.swift in Sources */,
|
|
||||||
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */,
|
|
||||||
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */,
|
|
||||||
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,
|
|
||||||
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */,
|
|
||||||
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */,
|
|
||||||
F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */,
|
|
||||||
F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */,
|
|
||||||
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */,
|
|
||||||
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */,
|
|
||||||
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */,
|
|
||||||
G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */,
|
|
||||||
G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -779,13 +694,6 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
E498A7962F41C35500D7D7A4 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -799,11 +707,6 @@
|
|||||||
target = A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */;
|
target = A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */;
|
||||||
targetProxy = A701719A2AA8E72000064C43 /* PBXContainerItemProxy */;
|
targetProxy = A701719A2AA8E72000064C43 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
E498A7A32F41C35500D7D7A4 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = E498A7992F41C35500D7D7A4 /* ShareExtension */;
|
|
||||||
targetProxy = E498A7A22F41C35500D7D7A4 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -933,14 +836,13 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "This app uses Reminders to save your grocery list items.";
|
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -951,12 +853,12 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.10.1;
|
MARKETING_VERSION = 1.10.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -978,14 +880,13 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "This app uses Reminders to save your grocery list items.";
|
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -996,12 +897,12 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.10.1;
|
MARKETING_VERSION = 1.10.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -1015,16 +916,17 @@
|
|||||||
A70171A72AA8E72000064C43 /* Debug */ = {
|
A70171A72AA8E72000064C43 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -1038,16 +940,17 @@
|
|||||||
A70171A82AA8E72000064C43 /* Release */ = {
|
A70171A82AA8E72000064C43 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -1061,15 +964,16 @@
|
|||||||
A70171AA2AA8E72000064C43 /* Debug */ = {
|
A70171AA2AA8E72000064C43 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -1083,15 +987,16 @@
|
|||||||
A70171AB2AA8E72000064C43 /* Release */ = {
|
A70171AB2AA8E72000064C43 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -1102,79 +1007,6 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
E498A7A62F41C35500D7D7A4 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook.shareExtension;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SKIP_INSTALL = YES;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
E498A7A72F41C35500D7D7A4 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
DEVELOPMENT_TEAM = JGFU6788BP;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
"@executable_path/../../Frameworks",
|
|
||||||
);
|
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook.shareExtension;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SKIP_INSTALL = YES;
|
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "";
|
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -1214,18 +1046,17 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
E498A7A92F41C35500D7D7A4 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
E498A7A62F41C35500D7D7A4 /* Debug */,
|
|
||||||
E498A7A72F41C35500D7D7A4 /* Release */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* 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";
|
||||||
@@ -1234,14 +1065,32 @@
|
|||||||
minimumVersion = 2.4.1;
|
minimumVersion = 2.4.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/evgenyneu/keychain-swift.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 24.0.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
A74D33BD2AF82AAE00D06555 /* SwiftSoup */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||||
|
productName = SwiftSoup;
|
||||||
|
};
|
||||||
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
|
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
|
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
|
||||||
productName = TPPDF;
|
productName = TPPDF;
|
||||||
};
|
};
|
||||||
|
A9E78A362BEA839100206866 /* KeychainSwift */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */;
|
||||||
|
productName = KeychainSwift;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = A70171762AA8E71900064C43 /* Project object */;
|
rootObject = A70171762AA8E71900064C43 /* Project object */;
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "314ca0b5cf5f134470eb4e9e12133500ae78d8b9a08f490e0065f2b3ceb4a25a",
|
"originHash" : "4b59f87688d89ebd5be92449f747ea79123f0d90515aea6b92e218b4860a3ef3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "keychain-swift",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/evgenyneu/keychain-swift.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608",
|
||||||
|
"version" : "24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftsoup",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
|
||||||
|
"version" : "2.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "tppdf",
|
"identity" : "tppdf",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,102 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
version = "1.7">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS Client"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
<Testables>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701718E2AA8E72000064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS ClientTests.xctest"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS ClientTests"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A70171982AA8E72000064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS ClientUITests.xctest"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS ClientUITests"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS Client"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS Client"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "2620"
|
|
||||||
wasCreatedForAppExtension = "YES"
|
|
||||||
version = "2.0">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES"
|
|
||||||
buildArchitectures = "Automatic">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "E498A7992F41C35500D7D7A4"
|
|
||||||
BuildableName = "ShareExtension.appex"
|
|
||||||
BlueprintName = "ShareExtension"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS Client"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
shouldAutocreateTestPlan = "YES">
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = ""
|
|
||||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
|
||||||
launchStyle = "0"
|
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS Client"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
askForAppToLaunch = "Yes"
|
|
||||||
launchAutomaticallySubstyle = "2">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
|
||||||
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
|
||||||
BlueprintName = "Nextcloud Cookbook iOS Client"
|
|
||||||
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
||||||
45
Nextcloud Cookbook iOS Client/Account/AccountManager.swift
Normal file
45
Nextcloud Cookbook iOS Client/Account/AccountManager.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// AccountManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 16.01.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class AccountManager {
|
||||||
|
var accounts: [any Account] = []
|
||||||
|
var authTokens: [String: String] = [:]
|
||||||
|
|
||||||
|
/// Save account as JSON.
|
||||||
|
func save(account: any Account, authToken: String?) async throws {
|
||||||
|
account.saveTokenToKeychain(authToken!)
|
||||||
|
let data = try JSONEncoder().encode(account)
|
||||||
|
|
||||||
|
let accountDir = account.accountType.rawValue + "/" + account.id.uuidString + "/account.json"
|
||||||
|
await DataStore.shared.save(data: data, toPath: accountDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load accounts from JSON files.
|
||||||
|
func loadAccounts() async throws {
|
||||||
|
// Read data from file or user defaults
|
||||||
|
for accountType in AccountType.allCases {
|
||||||
|
// List all account UUIDs under the /accountType directory
|
||||||
|
let accountUUIDs = DataStore.shared.listAllFolders(dir: accountType.rawValue + "/")
|
||||||
|
|
||||||
|
// Decode each account and fetch the authToken
|
||||||
|
for accountUUID in accountUUIDs {
|
||||||
|
do {
|
||||||
|
guard let account = try await DataStore.shared.loadDynamic(fromPath: accountType.rawValue + "/" + accountUUID + "/account.json", type: accountType.accountType) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
authTokens[accountUUID] = (account as! any Account).getTokenFromKeychain() ?? ""
|
||||||
|
self.accounts.append(account as! (any Account))
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Nextcloud Cookbook iOS Client/Account/AccountProtocol.swift
Normal file
49
Nextcloud Cookbook iOS Client/Account/AccountProtocol.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// AccountProtocol.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 16.01.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import KeychainSwift
|
||||||
|
|
||||||
|
|
||||||
|
enum AccountType: String, Codable, CaseIterable {
|
||||||
|
case cookbook = "cookbook"
|
||||||
|
case local = "local"
|
||||||
|
|
||||||
|
var accountType: any Decodable.Type {
|
||||||
|
switch self {
|
||||||
|
case .cookbook: return CookbookAccount.self
|
||||||
|
case .local: return LocalAccount.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol Account: Codable, Identifiable {
|
||||||
|
/// A unique identifier for this account
|
||||||
|
var id: UUID { get }
|
||||||
|
|
||||||
|
/// A name for the account that can be displayed in the UI
|
||||||
|
var displayName: String { get }
|
||||||
|
|
||||||
|
/// For differentiating account types when decoding
|
||||||
|
var accountType: AccountType { get }
|
||||||
|
|
||||||
|
/// Base endpoint URL
|
||||||
|
var baseURL: URL { get }
|
||||||
|
|
||||||
|
/// Account username
|
||||||
|
var username: String { get }
|
||||||
|
|
||||||
|
/// For storing/retrieving tokens from Keychain
|
||||||
|
func saveTokenToKeychain(_ token: String)
|
||||||
|
func getTokenFromKeychain() -> String?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//
|
||||||
|
// CookbookAccount.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 24.01.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import KeychainSwift
|
||||||
|
|
||||||
|
|
||||||
|
struct CookbookAccount: Account {
|
||||||
|
let accountType: AccountType = .cookbook
|
||||||
|
|
||||||
|
let id: UUID
|
||||||
|
var displayName: String = "Nextcloud Cookbook Account"
|
||||||
|
|
||||||
|
let baseURL: URL
|
||||||
|
let username: String
|
||||||
|
|
||||||
|
/// Keychain convenience
|
||||||
|
func saveTokenToKeychain(_ token: String) {
|
||||||
|
let keychain = KeychainSwift()
|
||||||
|
keychain.set(token, forKey: "token-\(id.uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTokenFromKeychain() -> String? {
|
||||||
|
let keychain = KeychainSwift()
|
||||||
|
return keychain.get("token-\(id.uuidString)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// LocalAccount.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 16.04.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct LocalAccount: Account {
|
||||||
|
let id: UUID
|
||||||
|
var displayName: String = "Local Account"
|
||||||
|
var accountType: AccountType = .local
|
||||||
|
|
||||||
|
let baseURL: URL = URL(filePath: "")!
|
||||||
|
let username: String = ""
|
||||||
|
|
||||||
|
/// Keychain convenience
|
||||||
|
func saveTokenToKeychain(_ token: String) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTokenFromKeychain() -> String? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,33 +6,29 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@Observable class AppState {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
@MainActor class AppState: ObservableObject {
|
@MainActor class AppState: ObservableObject {
|
||||||
@Published var categories: [Category] = []
|
@Published var categories: [Category] = []
|
||||||
@Published var recipes: [String: [Recipe]] = [:]
|
@Published var recipes: [String: [CookbookApiRecipeV1]] = [:]
|
||||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
@Published var recipeDetails: [Int: CookbookApiRecipeDetailV1] = [:]
|
||||||
@Published var timers: [String: RecipeTimer] = [:]
|
@Published var timers: [String: RecipeTimer] = [:]
|
||||||
@Published var categoryImages: [String: UIImage] = [:]
|
|
||||||
@Published var categoryImageRecipeIds: Set<Int> = []
|
|
||||||
@Published var recentRecipes: [Recipe] = []
|
|
||||||
@Published var categoryAccessDates: [String: Date] = [:]
|
|
||||||
@Published var manualCategoryOrder: [String] = []
|
|
||||||
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(api: CookbookApiProtocol? = nil) {
|
init() {
|
||||||
Logger.network.debug("Created AppState")
|
print("Created MainViewModel")
|
||||||
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)"
|
||||||
@@ -46,34 +42,55 @@ 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 {
|
||||||
do {
|
let (categories, _) = await cookbookApi.getCategories(
|
||||||
let categories = try await api.getCategories()
|
auth: UserSettings.shared.authString
|
||||||
Logger.data.debug("Successfully loaded categories")
|
)
|
||||||
|
if let categories = categories {
|
||||||
|
print("Successfully loaded categories")
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
await saveLocal(self.categories, path: "categories.data")
|
await saveLocal(self.categories, path: "categories.data")
|
||||||
} catch {
|
} else {
|
||||||
Logger.data.debug("Loading categories from store ...")
|
// If there's no server connection, try loading categories from local storage
|
||||||
|
print("Loading categories from store ...")
|
||||||
if let categories: [Category] = await loadLocal(path: "categories.data") {
|
if let categories: [Category] = await loadLocal(path: "categories.data") {
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
Logger.data.debug("Loaded categories from local store")
|
print("Success!")
|
||||||
} else {
|
} else {
|
||||||
Logger.data.error("Failed to load categories from local store")
|
print("Failure!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize the lastUpdates with distantPast dates, so that each recipeDetail is updated on launch for all categories
|
||||||
for category in self.categories {
|
for category in self.categories {
|
||||||
lastUpdates[category.name] = Date.distantPast
|
lastUpdates[category.name] = Date.distantPast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Fetches recipes for a specified category from either the server or local storage.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- name: The name of the category. Use "*" to fetch recipes without assigned categories.
|
||||||
|
- needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first.
|
||||||
|
|
||||||
|
This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage.
|
||||||
|
|
||||||
|
- Note: The category name "*" is used for all uncategorized recipes.
|
||||||
|
|
||||||
|
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
||||||
|
*/
|
||||||
func getCategory(named name: String, fetchMode: FetchMode) async {
|
func getCategory(named name: String, fetchMode: FetchMode) async {
|
||||||
Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))")
|
print("getCategory(\(name), fetchMode: \(fetchMode))")
|
||||||
func getLocal() async -> Bool {
|
func getLocal() async -> Bool {
|
||||||
let categoryString = name == "*" ? "_" : name
|
if let recipes: [CookbookApiRecipeV1] = await loadLocal(path: "category_\(categoryString).data") {
|
||||||
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
|
|
||||||
self.recipes[name] = recipes
|
self.recipes[name] = recipes
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -81,19 +98,22 @@ import UIKit
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getServer(store: Bool = false) async -> Bool {
|
func getServer(store: Bool = false) async -> Bool {
|
||||||
let categoryString = name == "*" ? "_" : name
|
let (recipes, _) = await cookbookApi.getCategory(
|
||||||
do {
|
auth: UserSettings.shared.authString,
|
||||||
let recipes = try await api.getCategory(named: categoryString)
|
named: categoryString
|
||||||
|
)
|
||||||
|
if let recipes = recipes {
|
||||||
self.recipes[name] = recipes
|
self.recipes[name] = recipes
|
||||||
if store {
|
if store {
|
||||||
await saveLocal(recipes, path: "category_\(categoryString).data")
|
await saveLocal(recipes, path: "category_\(categoryString).data")
|
||||||
}
|
}
|
||||||
|
//userSettings.lastUpdate = Date()
|
||||||
return true
|
return true
|
||||||
} catch {
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
let categoryString = name == "*" ? "_" : name
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
case .preferLocal:
|
case .preferLocal:
|
||||||
if await getLocal() { return }
|
if await getLocal() { return }
|
||||||
@@ -108,8 +128,6 @@ 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)
|
||||||
@@ -123,10 +141,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) {
|
||||||
Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))")
|
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)")
|
||||||
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||||
} else {
|
} else {
|
||||||
Logger.data.debug("\(recipe.name) is up to date.")
|
print("\(recipe.name) is up to date.")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||||
@@ -134,39 +152,72 @@ import UIKit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRecipes() async -> [Recipe] {
|
/**
|
||||||
do {
|
Asynchronously retrieves all recipes either from the server or the locally cached data.
|
||||||
return try await api.getRecipes()
|
|
||||||
} catch {
|
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.
|
||||||
Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)")
|
|
||||||
|
- 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 -> [CookbookApiRecipeV1] {
|
||||||
|
let (recipes, error) = await cookbookApi.getRecipes(
|
||||||
|
auth: UserSettings.shared.authString
|
||||||
|
)
|
||||||
|
if let recipes = recipes {
|
||||||
|
return recipes
|
||||||
|
} else if let error = error {
|
||||||
|
print(error)
|
||||||
}
|
}
|
||||||
var allRecipes: [Recipe] = []
|
var allRecipes: [CookbookApiRecipeV1] = []
|
||||||
for category in categories {
|
for category in categories {
|
||||||
if let recipeArray = self.recipes[category.name] {
|
if let recipeArray = self.recipes[category.name] {
|
||||||
allRecipes.append(contentsOf: recipeArray)
|
allRecipes.append(contentsOf: recipeArray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return allRecipes.sorted(by: { $0.name < $1.name })
|
return allRecipes.sorted(by: {
|
||||||
|
$0.name < $1.name
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
|
/**
|
||||||
func getLocal() async -> RecipeDetail? {
|
Asynchronously retrieves a recipe detail either from the server or locally cached data.
|
||||||
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
|
|
||||||
|
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 -> CookbookApiRecipeDetailV1? {
|
||||||
|
func getLocal() async -> CookbookApiRecipeDetailV1? {
|
||||||
|
if let recipe: CookbookApiRecipeDetailV1 = await loadLocal(path: "recipe\(id).data") { return recipe }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getServer() async -> RecipeDetail? {
|
func getServer() async -> CookbookApiRecipeDetailV1? {
|
||||||
do {
|
let (recipe, error) = await cookbookApi.getRecipe(
|
||||||
let recipe = try await api.getRecipe(id: id)
|
auth: UserSettings.shared.authString,
|
||||||
|
id: id
|
||||||
|
)
|
||||||
|
if let recipe = recipe {
|
||||||
if save {
|
if save {
|
||||||
self.recipeDetails[id] = recipe
|
self.recipeDetails[id] = recipe
|
||||||
await self.saveLocal(recipe, path: "recipe\(id).data")
|
await self.saveLocal(recipe, path: "recipe\(id).data")
|
||||||
}
|
}
|
||||||
return recipe
|
return recipe
|
||||||
} catch {
|
} else if let error = error {
|
||||||
Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)")
|
print(error)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
@@ -184,6 +235,18 @@ 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")
|
||||||
@@ -204,24 +267,48 @@ 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 {
|
||||||
return dataStore.recipeDetailExists(recipeId: recipeId)
|
if (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? {
|
||||||
do {
|
let (image, _) = await cookbookApi.getImage(
|
||||||
return try await api.getImage(id: id, size: size)
|
auth: UserSettings.shared.authString,
|
||||||
} catch {
|
id: id,
|
||||||
|
size: size
|
||||||
|
)
|
||||||
|
if let image = image { return image }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
case .preferLocal:
|
case .preferLocal:
|
||||||
@@ -273,19 +360,27 @@ 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]? {
|
||||||
do {
|
let (tags, _) = await cookbookApi.getTags(
|
||||||
return try await api.getTags()
|
auth: UserSettings.shared.authString
|
||||||
} catch {
|
)
|
||||||
return nil
|
return tags
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch fetchMode {
|
switch fetchMode {
|
||||||
@@ -309,78 +404,6 @@ 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
|
|
||||||
self.categoryImageRecipeIds.insert(recipe.recipe_id)
|
|
||||||
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: - Category sorting
|
|
||||||
|
|
||||||
func trackCategoryAccess(_ categoryName: String) {
|
|
||||||
categoryAccessDates[categoryName] = Date()
|
|
||||||
Task {
|
|
||||||
await saveLocal(categoryAccessDates, path: "category_access_dates.data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadCategoryAccessDates() async {
|
|
||||||
if let loaded: [String: Date] = await loadLocal(path: "category_access_dates.data") {
|
|
||||||
self.categoryAccessDates = loaded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateManualCategoryOrder(_ order: [String]) {
|
|
||||||
manualCategoryOrder = order
|
|
||||||
Task {
|
|
||||||
await saveLocal(manualCategoryOrder, path: "manual_category_order.data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadManualCategoryOrder() async {
|
|
||||||
if let loaded: [String] = await loadLocal(path: "manual_category_order.data") {
|
|
||||||
self.manualCategoryOrder = loaded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Data management
|
|
||||||
|
|
||||||
func deleteAllData() {
|
func deleteAllData() {
|
||||||
if dataStore.clearAll() {
|
if dataStore.clearAll() {
|
||||||
self.categories = []
|
self.categories = []
|
||||||
@@ -391,13 +414,30 @@ 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? {
|
||||||
do {
|
let (error) = await cookbookApi.deleteRecipe(
|
||||||
try await api.deleteRecipe(id: id)
|
auth: UserSettings.shared.authString,
|
||||||
} catch {
|
id: id
|
||||||
|
)
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
return .REQUEST_DROPPED
|
return .REQUEST_DROPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = "recipe\(id).data"
|
let path = "recipe\(id).data"
|
||||||
dataStore.delete(path: path)
|
dataStore.delete(path: path)
|
||||||
if recipes[categoryName] != nil {
|
if recipes[categoryName] != nil {
|
||||||
@@ -406,55 +446,89 @@ import UIKit
|
|||||||
})
|
})
|
||||||
recipeDetails.removeValue(forKey: id)
|
recipeDetails.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
recentRecipes.removeAll { $0.recipe_id == id }
|
|
||||||
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Asynchronously checks the server connection by attempting to fetch categories.
|
||||||
|
|
||||||
|
This function attempts to fetch categories from the server using the provided `api` to check the server connection status. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it returns `false`.
|
||||||
|
|
||||||
|
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
```swift
|
||||||
|
let isConnected = await mainViewModel.checkServerConnection()
|
||||||
|
*/
|
||||||
func checkServerConnection() async -> Bool {
|
func checkServerConnection() async -> Bool {
|
||||||
do {
|
let (categories, _) = await cookbookApi.getCategories(
|
||||||
let categories = try await api.getCategories()
|
auth: UserSettings.shared.authString
|
||||||
|
)
|
||||||
|
if let categories = categories {
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
await saveLocal(categories, path: "categories.data")
|
await saveLocal(categories, path: "categories.data")
|
||||||
return true
|
return true
|
||||||
} catch {
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) {
|
/**
|
||||||
do {
|
Asynchronously uploads a recipe to the server.
|
||||||
|
|
||||||
|
This function attempts to create or update a recipe on the server using the provided `api`. If the server connection is successful, it uploads the provided `recipeDetail`. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
|
||||||
|
|
||||||
|
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
|
||||||
|
|
||||||
|
- Parameters:
|
||||||
|
- recipeDetail: The detailed information of the recipe to upload.
|
||||||
|
- createNew: If true, creates a new recipe on the server; otherwise, updates an existing one.
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
```swift
|
||||||
|
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
|
||||||
|
*/
|
||||||
|
func uploadRecipe(recipeDetail: CookbookApiRecipeDetailV1, createNew: Bool) async -> RequestAlert? {
|
||||||
|
var error: NetworkError? = nil
|
||||||
if createNew {
|
if createNew {
|
||||||
let id = try await api.createRecipe(recipeDetail)
|
error = await cookbookApi.createRecipe(
|
||||||
return (id, nil)
|
auth: UserSettings.shared.authString,
|
||||||
|
recipe: recipeDetail
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let id = try await api.updateRecipe(recipeDetail)
|
error = await cookbookApi.updateRecipe(
|
||||||
return (id, nil)
|
auth: UserSettings.shared.authString,
|
||||||
|
recipe: recipeDetail
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch {
|
if error != nil {
|
||||||
return (nil, .REQUEST_DROPPED)
|
return .REQUEST_DROPPED
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
func importRecipe(url: String) async -> (CookbookApiRecipeDetailV1?, RequestAlert?) {
|
||||||
do {
|
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
|
||||||
let recipeDetail = try await api.importRecipe(url: url)
|
let (recipeDetail, error) = await cookbookApi.importRecipe(
|
||||||
|
auth: UserSettings.shared.authString,
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
if error != nil {
|
||||||
|
return (nil, .REQUEST_DROPPED)
|
||||||
|
}
|
||||||
return (recipeDetail, nil)
|
return (recipeDetail, nil)
|
||||||
} catch {
|
|
||||||
return (nil, .REQUEST_DROPPED)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Local storage helpers
|
|
||||||
|
|
||||||
extension AppState {
|
extension AppState {
|
||||||
func loadLocal<T: Codable>(path: String) async -> T? {
|
func loadLocal<T: Codable>(path: String) async -> T? {
|
||||||
do {
|
do {
|
||||||
return try await dataStore.load(fromPath: path)
|
return try await dataStore.load(fromPath: path)
|
||||||
} catch {
|
} catch (let error) {
|
||||||
Logger.data.debug("Failed to load local data: \(error.localizedDescription)")
|
print(error)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +546,7 @@ extension AppState {
|
|||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.data.debug("Could not find image in local storage.")
|
print("Could not find image in local storage.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -514,20 +588,24 @@ 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 {
|
||||||
Logger.data.debug("No update needed for \(category)")
|
print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
|
||||||
return false
|
return false
|
||||||
} else {
|
} else {
|
||||||
Logger.data.debug("Update needed for \(category)")
|
print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Logger.data.debug("Date parse failed, update needed for \(category)")
|
print("String is not a date. Update needed.")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -559,3 +637,4 @@ extension AppState {
|
|||||||
timers.removeValue(forKey: recipeId)
|
timers.removeValue(forKey: recipeId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// AppearanceMode.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum AppearanceMode: String, CaseIterable {
|
|
||||||
case system = "system"
|
|
||||||
case light = "light"
|
|
||||||
case dark = "dark"
|
|
||||||
|
|
||||||
func descriptor() -> String {
|
|
||||||
switch self {
|
|
||||||
case .system: return String(localized: "System")
|
|
||||||
case .light: return String(localized: "Light")
|
|
||||||
case .dark: return String(localized: "Dark")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let allValues: [AppearanceMode] = AppearanceMode.allCases
|
|
||||||
}
|
|
||||||
44
Nextcloud Cookbook iOS Client/Data/AuthManager.swift
Normal file
44
Nextcloud Cookbook iOS Client/Data/AuthManager.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// AuthManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 30.05.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import KeychainSwift
|
||||||
|
|
||||||
|
class AuthManager {
|
||||||
|
static let shared = AuthManager()
|
||||||
|
let keychain = KeychainSwift()
|
||||||
|
|
||||||
|
var authString: String? = nil
|
||||||
|
|
||||||
|
private let nextcloudUsernameKey = "nextcloud_username"
|
||||||
|
private let nextcloudAuthStringKey = "nextcloud_auth_string" // Stored as base64
|
||||||
|
|
||||||
|
func saveNextcloudCredentials(username: String, appPassword: String) {
|
||||||
|
keychain.set(username, forKey: nextcloudUsernameKey)
|
||||||
|
|
||||||
|
let loginString = "\(username):\(appPassword)"
|
||||||
|
if let loginData = loginString.data(using: .utf8) {
|
||||||
|
keychain.set(loginData.base64EncodedString(), forKey: nextcloudAuthStringKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNextcloudCredentials() -> (username: String?, authString: String?) {
|
||||||
|
let username = keychain.get(nextcloudUsernameKey)
|
||||||
|
let authString = keychain.get(nextcloudAuthStringKey)
|
||||||
|
|
||||||
|
return (username, authString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAuthString() {
|
||||||
|
authString = keychain.get(nextcloudAuthStringKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteNextcloudCredentials() {
|
||||||
|
keychain.delete(nextcloudUsernameKey)
|
||||||
|
keychain.delete(nextcloudAuthStringKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
//
|
|
||||||
// CategorySortMode.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum CategorySortMode: String, CaseIterable {
|
|
||||||
case recentlyUsed = "recentlyUsed"
|
|
||||||
case alphabetical = "alphabetical"
|
|
||||||
case manual = "manual"
|
|
||||||
|
|
||||||
func descriptor() -> String {
|
|
||||||
switch self {
|
|
||||||
case .recentlyUsed: return String(localized: "Recently Used")
|
|
||||||
case .alphabetical: return String(localized: "Alphabetical")
|
|
||||||
case .manual: return String(localized: "Manual")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .recentlyUsed: return "clock"
|
|
||||||
case .alphabetical: return "textformat.abc"
|
|
||||||
case .manual: return "line.3.horizontal"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var supportsInvert: Bool {
|
|
||||||
self != .manual
|
|
||||||
}
|
|
||||||
|
|
||||||
static let allValues: [CategorySortMode] = CategorySortMode.allCases
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RecipeSortMode: String, CaseIterable {
|
|
||||||
case recentlyAdded = "recentlyAdded"
|
|
||||||
case alphabetical = "alphabetical"
|
|
||||||
|
|
||||||
func descriptor() -> String {
|
|
||||||
switch self {
|
|
||||||
case .recentlyAdded: return String(localized: "Recently Added")
|
|
||||||
case .alphabetical: return String(localized: "Alphabetical")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var iconName: String {
|
|
||||||
switch self {
|
|
||||||
case .recentlyAdded: return "clock"
|
|
||||||
case .alphabetical: return "textformat.abc"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let allValues: [RecipeSortMode] = RecipeSortMode.allCases
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
class DataStore {
|
class DataStore {
|
||||||
@@ -38,12 +37,21 @@ class DataStore {
|
|||||||
guard let data = try? Data(contentsOf: fileURL) else {
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let storedRecipes = try JSONDecoder().decode(D.self, from: data)
|
let decodedData = try JSONDecoder().decode(D.self, from: data)
|
||||||
return storedRecipes
|
return decodedData
|
||||||
}
|
}
|
||||||
return try await task.value
|
return try await task.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadDynamic(fromPath path: String, type: Decodable.Type) async throws -> Any? {
|
||||||
|
let fileURL = try Self.fileURL(appending: path)
|
||||||
|
guard let data = try? Data(contentsOf: fileURL) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let decoded = try JSONDecoder().decode(type, from: data)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
func save<D: Encodable>(data: D, toPath path: String) async {
|
func save<D: Encodable>(data: D, toPath path: String) async {
|
||||||
let task = Task {
|
let task = Task {
|
||||||
let data = try JSONEncoder().encode(data)
|
let data = try JSONEncoder().encode(data)
|
||||||
@@ -53,7 +61,7 @@ class DataStore {
|
|||||||
do {
|
do {
|
||||||
_ = try await task.value
|
_ = try await task.value
|
||||||
} catch {
|
} catch {
|
||||||
Logger.data.error("Could not save data (path: \(path))")
|
print("Could not save data (path: \(path)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,19 +78,46 @@ class DataStore {
|
|||||||
return fileManager.fileExists(atPath: folderPath + filePath)
|
return fileManager.fileExists(atPath: folderPath + filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listAllFolders(dir: String) -> [String] {
|
||||||
|
guard let baseURL = try? Self.fileURL() else {
|
||||||
|
print("Failed to retrieve documents directory.")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetURL = baseURL.appendingPathComponent(dir)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let contents = try fileManager.contentsOfDirectory(at: targetURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
||||||
|
|
||||||
|
let folders = contents.filter { url in
|
||||||
|
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
|
||||||
|
}
|
||||||
|
return folders.map { $0.lastPathComponent }
|
||||||
|
} catch {
|
||||||
|
print("Error listing folders in \(dir): \(error)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func clearAll() -> Bool {
|
func clearAll() -> Bool {
|
||||||
Logger.data.debug("Attempting to delete all data ...")
|
print("Attempting to delete all data ...")
|
||||||
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
|
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
|
||||||
|
print("Folder path: ", folderPath)
|
||||||
do {
|
do {
|
||||||
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
|
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
|
||||||
for filePath in filePaths {
|
for filePath in filePaths {
|
||||||
|
print("File path: ", filePath)
|
||||||
try fileManager.removeItem(atPath: folderPath + filePath)
|
try fileManager.removeItem(atPath: folderPath + filePath)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)")
|
print("Could not delete documents folder contents: \(error)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
Logger.data.debug("All data deleted successfully.")
|
print("Done.")
|
||||||
return true
|
return true
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
//
|
|
||||||
// GroceryListManager.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import EventKit
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class GroceryListManager: ObservableObject {
|
|
||||||
@Published var groceryDict: [String: GroceryRecipe] = [:]
|
|
||||||
|
|
||||||
let localStore = GroceryList()
|
|
||||||
let remindersStore = RemindersGroceryStore()
|
|
||||||
var syncManager: GroceryStateSyncManager?
|
|
||||||
|
|
||||||
/// Recipe IDs modified by our own CRUD — skip these in the onDataChanged callback
|
|
||||||
/// to avoid duplicate syncs.
|
|
||||||
private var recentlyModifiedByUs: Set<String> = []
|
|
||||||
|
|
||||||
private var mode: GroceryListMode {
|
|
||||||
GroceryListMode(rawValue: UserSettings.shared.groceryListMode) ?? .inApp
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
remindersStore.onDataChanged = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
if self.mode == .appleReminders {
|
|
||||||
self.groceryDict = self.remindersStore.groceryDict
|
|
||||||
self.recentlyModifiedByUs.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func configureSyncManager(appState: AppState) {
|
|
||||||
syncManager = GroceryStateSyncManager(appState: appState, groceryManager: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Grocery Operations
|
|
||||||
|
|
||||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
localStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
|
||||||
groceryDict = localStore.groceryDict
|
|
||||||
case .appleReminders:
|
|
||||||
recentlyModifiedByUs.insert(recipeId)
|
|
||||||
remindersStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
|
||||||
groceryDict = remindersStore.groceryDict
|
|
||||||
}
|
|
||||||
syncManager?.clearPendingRemoval(recipeId: recipeId)
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
localStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
|
|
||||||
groceryDict = localStore.groceryDict
|
|
||||||
case .appleReminders:
|
|
||||||
recentlyModifiedByUs.insert(recipeId)
|
|
||||||
remindersStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
|
|
||||||
groceryDict = remindersStore.groceryDict
|
|
||||||
}
|
|
||||||
syncManager?.clearPendingRemoval(recipeId: recipeId)
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
localStore.deleteItem(itemName, fromRecipe: recipeId)
|
|
||||||
groceryDict = localStore.groceryDict
|
|
||||||
case .appleReminders:
|
|
||||||
recentlyModifiedByUs.insert(recipeId)
|
|
||||||
remindersStore.deleteItem(itemName, fromRecipe: recipeId)
|
|
||||||
}
|
|
||||||
if groceryDict[recipeId] == nil {
|
|
||||||
syncManager?.trackPendingRemoval(recipeId: recipeId)
|
|
||||||
}
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteGroceryRecipe(_ recipeId: String) {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
localStore.deleteGroceryRecipe(recipeId)
|
|
||||||
groceryDict = localStore.groceryDict
|
|
||||||
case .appleReminders:
|
|
||||||
recentlyModifiedByUs.insert(recipeId)
|
|
||||||
remindersStore.deleteGroceryRecipe(recipeId)
|
|
||||||
}
|
|
||||||
syncManager?.trackPendingRemoval(recipeId: recipeId)
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteAll() {
|
|
||||||
let recipeIds = Array(groceryDict.keys)
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
localStore.deleteAll()
|
|
||||||
groceryDict = localStore.groceryDict
|
|
||||||
case .appleReminders:
|
|
||||||
recentlyModifiedByUs.formUnion(recipeIds)
|
|
||||||
remindersStore.deleteAll()
|
|
||||||
}
|
|
||||||
for recipeId in recipeIds {
|
|
||||||
syncManager?.trackPendingRemoval(recipeId: recipeId)
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
localStore.toggleItemChecked(groceryItem)
|
|
||||||
case .appleReminders:
|
|
||||||
// Reminders don't support checked state in our model
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsItem(at recipeId: String, item: String) -> Bool {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
return localStore.containsItem(at: recipeId, item: item)
|
|
||||||
case .appleReminders:
|
|
||||||
return remindersStore.containsItem(at: recipeId, item: item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsRecipe(_ recipeId: String) -> Bool {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
return localStore.containsRecipe(recipeId)
|
|
||||||
case .appleReminders:
|
|
||||||
return remindersStore.containsRecipe(recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func load() async {
|
|
||||||
switch mode {
|
|
||||||
case .inApp:
|
|
||||||
await localStore.load()
|
|
||||||
groceryDict = localStore.groceryDict
|
|
||||||
case .appleReminders:
|
|
||||||
await remindersStore.load()
|
|
||||||
groceryDict = remindersStore.groceryDict
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func save() {
|
|
||||||
if mode == .inApp {
|
|
||||||
localStore.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reminders Helpers (for Settings UI)
|
|
||||||
|
|
||||||
var remindersPermissionStatus: EKAuthorizationStatus {
|
|
||||||
remindersStore.checkPermissionStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestRemindersAccess() async -> Bool {
|
|
||||||
await remindersStore.requestAccess()
|
|
||||||
}
|
|
||||||
|
|
||||||
func availableReminderLists() -> [EKCalendar] {
|
|
||||||
remindersStore.availableReminderLists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
//
|
|
||||||
// GroceryListMode.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum GroceryListMode: String, CaseIterable {
|
|
||||||
case inApp = "inApp"
|
|
||||||
case appleReminders = "appleReminders"
|
|
||||||
|
|
||||||
func descriptor() -> String {
|
|
||||||
switch self {
|
|
||||||
case .inApp: return String(localized: "In-App")
|
|
||||||
case .appleReminders: return String(localized: "Apple Reminders")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static let allValues: [GroceryListMode] = GroceryListMode.allCases
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
//
|
|
||||||
// GroceryStateModels.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks grocery list state for a recipe, stored as `_groceryState` in the recipe JSON on the server.
|
|
||||||
struct GroceryState: Codable {
|
|
||||||
var version: Int = 1
|
|
||||||
var lastModified: String
|
|
||||||
var items: [String: GroceryItemState]
|
|
||||||
|
|
||||||
init(lastModified: String = GroceryStateDate.now(), items: [String: GroceryItemState] = [:]) {
|
|
||||||
self.version = 1
|
|
||||||
self.lastModified = lastModified
|
|
||||||
self.items = items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GroceryItemState: Codable {
|
|
||||||
enum Status: String, Codable {
|
|
||||||
case added
|
|
||||||
case completed
|
|
||||||
case removed
|
|
||||||
}
|
|
||||||
|
|
||||||
var status: Status
|
|
||||||
var addedAt: String
|
|
||||||
var modifiedAt: String
|
|
||||||
|
|
||||||
init(status: Status, addedAt: String = GroceryStateDate.now(), modifiedAt: String = GroceryStateDate.now()) {
|
|
||||||
self.status = status
|
|
||||||
self.addedAt = addedAt
|
|
||||||
self.modifiedAt = modifiedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ISO 8601 date helpers. Dates are stored as strings to avoid coupling to a parent encoder's date strategy.
|
|
||||||
enum GroceryStateDate {
|
|
||||||
private static let formatter: ISO8601DateFormatter = {
|
|
||||||
let f = ISO8601DateFormatter()
|
|
||||||
f.formatOptions = [.withInternetDateTime]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func now() -> String {
|
|
||||||
formatter.string(from: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
static func date(from string: String) -> Date? {
|
|
||||||
formatter.date(from: string)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func string(from date: Date) -> String {
|
|
||||||
formatter.string(from: date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
//
|
|
||||||
// GroceryStateSyncManager.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class GroceryStateSyncManager {
|
|
||||||
private weak var appState: AppState?
|
|
||||||
private weak var groceryManager: GroceryListManager?
|
|
||||||
|
|
||||||
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
|
||||||
private let debounceInterval: TimeInterval = 2.0
|
|
||||||
private var isReconciling = false
|
|
||||||
|
|
||||||
private let dataStore = DataStore()
|
|
||||||
private let pendingRemovalPath = "grocery_pending_removals.data"
|
|
||||||
private(set) var pendingRemovalRecipeIds: Set<String> = []
|
|
||||||
|
|
||||||
init(appState: AppState, groceryManager: GroceryListManager) {
|
|
||||||
self.appState = appState
|
|
||||||
self.groceryManager = groceryManager
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Push Flow
|
|
||||||
|
|
||||||
/// Debounced sync trigger. Waits `debounceInterval` seconds then pushes state for the recipe.
|
|
||||||
func scheduleSync(forRecipeId recipeId: String) {
|
|
||||||
guard UserSettings.shared.grocerySyncEnabled else { return }
|
|
||||||
guard !isReconciling else { return }
|
|
||||||
|
|
||||||
debounceTimers[recipeId]?.cancel()
|
|
||||||
debounceTimers[recipeId] = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(2_000_000_000))
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self?.pushGroceryState(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds local grocery state, fetches server recipe, merges, and PUTs back.
|
|
||||||
func pushGroceryState(forRecipeId recipeId: String) async {
|
|
||||||
guard let appState, let groceryManager else { return }
|
|
||||||
guard let recipeIdInt = Int(recipeId) else { return }
|
|
||||||
|
|
||||||
// Fetch latest recipe from server first so we can detect deletions
|
|
||||||
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
|
|
||||||
Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let serverState = serverRecipe.groceryState
|
|
||||||
|
|
||||||
// Build local state, passing server state so deleted items can be marked .removed
|
|
||||||
let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager, serverState: serverState)
|
|
||||||
|
|
||||||
// Merge local state with server state
|
|
||||||
let merged = mergeStates(local: localState, server: serverState)
|
|
||||||
|
|
||||||
// Upload merged state
|
|
||||||
var updatedRecipe = serverRecipe
|
|
||||||
updatedRecipe.groceryState = merged
|
|
||||||
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
|
|
||||||
if let alert {
|
|
||||||
Logger.data.error("Grocery sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pull Flow
|
|
||||||
|
|
||||||
/// Reconciles server grocery state with local grocery data. Called when a recipe is loaded.
|
|
||||||
func reconcileFromServer(serverState: GroceryState?, recipeId: String, recipeName: String) {
|
|
||||||
guard let groceryManager else { return }
|
|
||||||
guard let serverState, !serverState.items.isEmpty else { return }
|
|
||||||
|
|
||||||
isReconciling = true
|
|
||||||
defer { isReconciling = false }
|
|
||||||
|
|
||||||
let localItems = Set(
|
|
||||||
groceryManager.groceryDict[recipeId]?.items.map(\.name) ?? []
|
|
||||||
)
|
|
||||||
|
|
||||||
for (itemName, itemState) in serverState.items {
|
|
||||||
switch itemState.status {
|
|
||||||
case .added:
|
|
||||||
if !localItems.contains(itemName) {
|
|
||||||
groceryManager.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
|
||||||
}
|
|
||||||
case .removed:
|
|
||||||
if localItems.contains(itemName) {
|
|
||||||
groceryManager.deleteItem(itemName, fromRecipe: recipeId)
|
|
||||||
}
|
|
||||||
case .completed:
|
|
||||||
// Don't re-add completed items; leave local state as-is
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Initial Sync
|
|
||||||
|
|
||||||
/// Pushes any local-only items and reconciles server items on app launch.
|
|
||||||
func performSync() async {
|
|
||||||
guard let appState, let groceryManager else { return }
|
|
||||||
|
|
||||||
await loadPendingRemovals()
|
|
||||||
|
|
||||||
let recipeIds = Array(groceryManager.groceryDict.keys)
|
|
||||||
for recipeId in recipeIds {
|
|
||||||
guard let recipeIdInt = Int(recipeId) else { continue }
|
|
||||||
|
|
||||||
// Push local state to server
|
|
||||||
await pushGroceryState(forRecipeId: recipeId)
|
|
||||||
|
|
||||||
// Fetch back and reconcile
|
|
||||||
if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) {
|
|
||||||
let recipeName = groceryManager.groceryDict[recipeId]?.name ?? serverRecipe.name
|
|
||||||
reconcileFromServer(
|
|
||||||
serverState: serverRecipe.groceryState,
|
|
||||||
recipeId: recipeId,
|
|
||||||
recipeName: recipeName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push deletion state for recipes whose items were fully removed
|
|
||||||
for recipeId in pendingRemovalRecipeIds {
|
|
||||||
guard !recipeIds.contains(recipeId) else {
|
|
||||||
// Recipe was re-added locally since removal was tracked; clear it
|
|
||||||
pendingRemovalRecipeIds.remove(recipeId)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
await pushGroceryState(forRecipeId: recipeId)
|
|
||||||
pendingRemovalRecipeIds.remove(recipeId)
|
|
||||||
}
|
|
||||||
savePendingRemovals()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Merge Logic
|
|
||||||
|
|
||||||
/// Merges local and server states using per-item last-writer-wins on `modifiedAt`.
|
|
||||||
private func mergeStates(local: GroceryState, server: GroceryState?) -> GroceryState {
|
|
||||||
guard let server else { return local }
|
|
||||||
|
|
||||||
var merged = local.items
|
|
||||||
for (itemName, serverItem) in server.items {
|
|
||||||
if let localItem = merged[itemName] {
|
|
||||||
// Both have the item — keep the one with the later modifiedAt
|
|
||||||
let localDate = GroceryStateDate.date(from: localItem.modifiedAt) ?? .distantPast
|
|
||||||
let serverDate = GroceryStateDate.date(from: serverItem.modifiedAt) ?? .distantPast
|
|
||||||
if serverDate > localDate {
|
|
||||||
merged[itemName] = serverItem
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Only server has this item — keep it
|
|
||||||
merged[itemName] = serverItem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Garbage collection: remove items that are removed/completed and older than 30 days
|
|
||||||
let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
|
||||||
merged = merged.filter { _, item in
|
|
||||||
if item.status == .added { return true }
|
|
||||||
guard let modDate = GroceryStateDate.date(from: item.modifiedAt) else { return true }
|
|
||||||
return modDate > thirtyDaysAgo
|
|
||||||
}
|
|
||||||
|
|
||||||
return GroceryState(
|
|
||||||
lastModified: GroceryStateDate.now(),
|
|
||||||
items: merged
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Build Local State
|
|
||||||
|
|
||||||
/// Builds a `GroceryState` from the current local grocery data for a recipe.
|
|
||||||
/// When `serverState` is provided, any server item with `.added` status that is
|
|
||||||
/// absent locally is emitted as `.removed` so the deletion propagates to the server.
|
|
||||||
private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager, serverState: GroceryState?) -> GroceryState {
|
|
||||||
var items: [String: GroceryItemState] = [:]
|
|
||||||
let now = GroceryStateDate.now()
|
|
||||||
|
|
||||||
// Existing local items
|
|
||||||
if let groceryRecipe = groceryManager.groceryDict[recipeId] {
|
|
||||||
for item in groceryRecipe.items {
|
|
||||||
let status: GroceryItemState.Status = item.isChecked ? .completed : .added
|
|
||||||
items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark items that exist on server as .added but are absent locally as .removed
|
|
||||||
if let serverState {
|
|
||||||
for (itemName, serverItem) in serverState.items {
|
|
||||||
if items[itemName] == nil && serverItem.status == .added {
|
|
||||||
items[itemName] = GroceryItemState(
|
|
||||||
status: .removed,
|
|
||||||
addedAt: serverItem.addedAt,
|
|
||||||
modifiedAt: now
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return GroceryState(lastModified: now, items: items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pending Removal Tracking
|
|
||||||
|
|
||||||
/// Records a recipe ID whose grocery items were fully removed, so that
|
|
||||||
/// `performSync` can push the deletion even after the key disappears
|
|
||||||
/// from `groceryDict`.
|
|
||||||
func trackPendingRemoval(recipeId: String) {
|
|
||||||
pendingRemovalRecipeIds.insert(recipeId)
|
|
||||||
savePendingRemovals()
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearPendingRemoval(recipeId: String) {
|
|
||||||
guard pendingRemovalRecipeIds.remove(recipeId) != nil else { return }
|
|
||||||
savePendingRemovals()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadPendingRemovals() async {
|
|
||||||
guard let loaded: Set<String> = try? await dataStore.load(fromPath: pendingRemovalPath) else { return }
|
|
||||||
pendingRemovalRecipeIds = loaded
|
|
||||||
}
|
|
||||||
|
|
||||||
private func savePendingRemovals() {
|
|
||||||
Task {
|
|
||||||
await dataStore.save(data: pendingRemovalRecipeIds, toPath: pendingRemovalPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
//
|
|
||||||
// MealPlanManager.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class MealPlanManager: ObservableObject {
|
|
||||||
@Published var entriesByDate: [String: [MealPlanEntry]] = [:]
|
|
||||||
|
|
||||||
private var assignmentsByRecipe: [String: MealPlanAssignment] = [:]
|
|
||||||
private var recipeNames: [String: String] = [:]
|
|
||||||
private let dataStore = DataStore()
|
|
||||||
var syncManager: MealPlanSyncManager?
|
|
||||||
var syncStartTime: String?
|
|
||||||
|
|
||||||
private static let persistencePath = "meal_plan.data"
|
|
||||||
|
|
||||||
// MARK: - Persistence
|
|
||||||
|
|
||||||
struct PersistenceData: Codable {
|
|
||||||
var assignmentsByRecipe: [String: MealPlanAssignment]
|
|
||||||
var recipeNames: [String: String]
|
|
||||||
}
|
|
||||||
|
|
||||||
func load() async {
|
|
||||||
do {
|
|
||||||
guard let data: PersistenceData = try await dataStore.load(fromPath: Self.persistencePath) else { return }
|
|
||||||
assignmentsByRecipe = data.assignmentsByRecipe
|
|
||||||
recipeNames = data.recipeNames
|
|
||||||
pruneOldEntries()
|
|
||||||
rebuildEntries()
|
|
||||||
} catch {
|
|
||||||
Logger.data.error("Unable to load meal plan data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func save() {
|
|
||||||
pruneOldEntries()
|
|
||||||
let data = PersistenceData(assignmentsByRecipe: assignmentsByRecipe, recipeNames: recipeNames)
|
|
||||||
Task {
|
|
||||||
await dataStore.save(data: data, toPath: Self.persistencePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func configureSyncManager(appState: AppState) {
|
|
||||||
syncManager = MealPlanSyncManager(appState: appState, mealPlanManager: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CRUD
|
|
||||||
|
|
||||||
func assignRecipe(recipeId: String, recipeName: String, toDates dates: [Date]) {
|
|
||||||
recipeNames[recipeId] = recipeName
|
|
||||||
var assignment = assignmentsByRecipe[recipeId] ?? MealPlanAssignment()
|
|
||||||
|
|
||||||
for date in dates {
|
|
||||||
let dayStr = MealPlanDate.dayString(from: date)
|
|
||||||
assignment.dates[dayStr] = MealPlanDateEntry(status: .assigned)
|
|
||||||
}
|
|
||||||
assignment.lastModified = MealPlanDate.now()
|
|
||||||
assignmentsByRecipe[recipeId] = assignment
|
|
||||||
|
|
||||||
rebuildEntries()
|
|
||||||
save()
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeRecipe(recipeId: String, fromDate dateString: String) {
|
|
||||||
guard var assignment = assignmentsByRecipe[recipeId] else { return }
|
|
||||||
|
|
||||||
assignment.dates[dateString] = MealPlanDateEntry(status: .removed)
|
|
||||||
assignment.lastModified = MealPlanDate.now()
|
|
||||||
assignmentsByRecipe[recipeId] = assignment
|
|
||||||
|
|
||||||
rebuildEntries()
|
|
||||||
save()
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAllAssignments(forRecipeId recipeId: String) {
|
|
||||||
guard var assignment = assignmentsByRecipe[recipeId] else { return }
|
|
||||||
|
|
||||||
let now = MealPlanDate.now()
|
|
||||||
for key in assignment.dates.keys {
|
|
||||||
assignment.dates[key] = MealPlanDateEntry(status: .removed, modifiedAt: now)
|
|
||||||
}
|
|
||||||
assignment.lastModified = now
|
|
||||||
assignmentsByRecipe[recipeId] = assignment
|
|
||||||
|
|
||||||
rebuildEntries()
|
|
||||||
save()
|
|
||||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Queries
|
|
||||||
|
|
||||||
func entries(for date: Date) -> [MealPlanEntry] {
|
|
||||||
let dayStr = MealPlanDate.dayString(from: date)
|
|
||||||
return entriesByDate[dayStr] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRecipeAssigned(_ recipeId: String, on date: Date) -> Bool {
|
|
||||||
let dayStr = MealPlanDate.dayString(from: date)
|
|
||||||
guard let assignment = assignmentsByRecipe[recipeId],
|
|
||||||
let entry = assignment.dates[dayStr] else { return false }
|
|
||||||
return entry.status == .assigned
|
|
||||||
}
|
|
||||||
|
|
||||||
func assignedDates(forRecipeId recipeId: String) -> [String] {
|
|
||||||
guard let assignment = assignmentsByRecipe[recipeId] else { return [] }
|
|
||||||
return assignment.dates.compactMap { key, entry in
|
|
||||||
entry.status == .assigned ? key : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assignment(forRecipeId recipeId: String) -> MealPlanAssignment? {
|
|
||||||
assignmentsByRecipe[recipeId]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reconciliation (Pull)
|
|
||||||
|
|
||||||
func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) {
|
|
||||||
guard let serverAssignment, !serverAssignment.dates.isEmpty else { return }
|
|
||||||
|
|
||||||
recipeNames[recipeId] = recipeName
|
|
||||||
var local = assignmentsByRecipe[recipeId] ?? MealPlanAssignment()
|
|
||||||
|
|
||||||
for (dayStr, serverEntry) in serverAssignment.dates {
|
|
||||||
if let localEntry = local.dates[dayStr] {
|
|
||||||
// Skip entries modified locally during this sync cycle
|
|
||||||
if let syncStart = syncStartTime,
|
|
||||||
let syncStartDate = MealPlanDate.date(from: syncStart),
|
|
||||||
let localModDate = MealPlanDate.date(from: localEntry.modifiedAt),
|
|
||||||
localModDate >= syncStartDate {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast
|
|
||||||
let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast
|
|
||||||
if serverDate > localDate {
|
|
||||||
local.dates[dayStr] = serverEntry
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
local.dates[dayStr] = serverEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
local.lastModified = MealPlanDate.now()
|
|
||||||
assignmentsByRecipe[recipeId] = local
|
|
||||||
|
|
||||||
rebuildEntries()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Internal
|
|
||||||
|
|
||||||
private func pruneOldEntries() {
|
|
||||||
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))!
|
|
||||||
var emptyRecipeIds: [String] = []
|
|
||||||
|
|
||||||
for (recipeId, var assignment) in assignmentsByRecipe {
|
|
||||||
assignment.dates = assignment.dates.filter { dayStr, _ in
|
|
||||||
guard let date = MealPlanDate.dateFromDay(dayStr) else { return true }
|
|
||||||
return date >= cutoff
|
|
||||||
}
|
|
||||||
if assignment.dates.isEmpty {
|
|
||||||
emptyRecipeIds.append(recipeId)
|
|
||||||
} else {
|
|
||||||
assignmentsByRecipe[recipeId] = assignment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for recipeId in emptyRecipeIds {
|
|
||||||
assignmentsByRecipe.removeValue(forKey: recipeId)
|
|
||||||
recipeNames.removeValue(forKey: recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rebuildEntries() {
|
|
||||||
var newEntries: [String: [MealPlanEntry]] = [:]
|
|
||||||
|
|
||||||
for (recipeId, assignment) in assignmentsByRecipe {
|
|
||||||
let name = recipeNames[recipeId] ?? "Recipe \(recipeId)"
|
|
||||||
for (dayStr, entry) in assignment.dates where entry.status == .assigned {
|
|
||||||
guard let date = MealPlanDate.dateFromDay(dayStr) else { continue }
|
|
||||||
let mealEntry = MealPlanEntry(
|
|
||||||
recipeId: recipeId,
|
|
||||||
recipeName: name,
|
|
||||||
date: date,
|
|
||||||
dateString: dayStr,
|
|
||||||
mealType: entry.mealType
|
|
||||||
)
|
|
||||||
newEntries[dayStr, default: []].append(mealEntry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort entries within each day by recipe name
|
|
||||||
for key in newEntries.keys {
|
|
||||||
newEntries[key]?.sort(by: { $0.recipeName < $1.recipeName })
|
|
||||||
}
|
|
||||||
|
|
||||||
entriesByDate = newEntries
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
//
|
|
||||||
// MealPlanModels.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks meal plan assignments for a recipe, stored as `_mealPlanAssignment` in the recipe JSON on the server.
|
|
||||||
struct MealPlanAssignment: Codable {
|
|
||||||
var version: Int = 1
|
|
||||||
var lastModified: String
|
|
||||||
var dates: [String: MealPlanDateEntry]
|
|
||||||
|
|
||||||
init(lastModified: String = MealPlanDate.now(), dates: [String: MealPlanDateEntry] = [:]) {
|
|
||||||
self.version = 1
|
|
||||||
self.lastModified = lastModified
|
|
||||||
self.dates = dates
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MealPlanDateEntry: Codable {
|
|
||||||
enum Status: String, Codable {
|
|
||||||
case assigned
|
|
||||||
case removed
|
|
||||||
}
|
|
||||||
|
|
||||||
var status: Status
|
|
||||||
var mealType: String?
|
|
||||||
var modifiedAt: String
|
|
||||||
|
|
||||||
init(status: Status, mealType: String? = nil, modifiedAt: String = MealPlanDate.now()) {
|
|
||||||
self.status = status
|
|
||||||
self.mealType = mealType
|
|
||||||
self.modifiedAt = modifiedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ISO 8601 date helpers for meal plan dates.
|
|
||||||
enum MealPlanDate {
|
|
||||||
private static let isoFormatter: ISO8601DateFormatter = {
|
|
||||||
let f = ISO8601DateFormatter()
|
|
||||||
f.formatOptions = [.withInternetDateTime]
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private static let dayFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "yyyy-MM-dd"
|
|
||||||
f.timeZone = .current
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func now() -> String {
|
|
||||||
isoFormatter.string(from: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
static func date(from string: String) -> Date? {
|
|
||||||
isoFormatter.date(from: string)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func string(from date: Date) -> String {
|
|
||||||
isoFormatter.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func dayString(from date: Date) -> String {
|
|
||||||
dayFormatter.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func dateFromDay(_ dayString: String) -> Date? {
|
|
||||||
dayFormatter.date(from: dayString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local-only aggregated view struct used by the UI.
|
|
||||||
struct MealPlanEntry: Identifiable {
|
|
||||||
let recipeId: String
|
|
||||||
let recipeName: String
|
|
||||||
let date: Date
|
|
||||||
let dateString: String
|
|
||||||
let mealType: String?
|
|
||||||
|
|
||||||
var id: String { "\(recipeId)-\(dateString)" }
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
//
|
|
||||||
// MealPlanSyncManager.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class MealPlanSyncManager {
|
|
||||||
private weak var appState: AppState?
|
|
||||||
private weak var mealPlanManager: MealPlanManager?
|
|
||||||
|
|
||||||
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
|
||||||
private let debounceInterval: TimeInterval = 2.0
|
|
||||||
|
|
||||||
init(appState: AppState, mealPlanManager: MealPlanManager) {
|
|
||||||
self.appState = appState
|
|
||||||
self.mealPlanManager = mealPlanManager
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Push Flow
|
|
||||||
|
|
||||||
func scheduleSync(forRecipeId recipeId: String) {
|
|
||||||
guard UserSettings.shared.mealPlanSyncEnabled else { return }
|
|
||||||
|
|
||||||
debounceTimers[recipeId]?.cancel()
|
|
||||||
debounceTimers[recipeId] = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(2_000_000_000))
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self?.pushMealPlanState(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pushMealPlanState(forRecipeId recipeId: String) async {
|
|
||||||
guard let appState, let mealPlanManager else { return }
|
|
||||||
guard let recipeIdInt = Int(recipeId) else { return }
|
|
||||||
|
|
||||||
let localAssignment = mealPlanManager.assignment(forRecipeId: recipeId)
|
|
||||||
|
|
||||||
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
|
|
||||||
Logger.data.error("Meal plan sync: failed to fetch recipe \(recipeId) from server")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let merged = mergeAssignments(local: localAssignment, server: serverRecipe.mealPlanAssignment)
|
|
||||||
|
|
||||||
var updatedRecipe = serverRecipe
|
|
||||||
updatedRecipe.mealPlanAssignment = merged
|
|
||||||
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
|
|
||||||
if let alert {
|
|
||||||
Logger.data.error("Meal plan sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pull Flow
|
|
||||||
|
|
||||||
func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) {
|
|
||||||
guard let mealPlanManager else { return }
|
|
||||||
mealPlanManager.reconcileFromServer(serverAssignment: serverAssignment, recipeId: recipeId, recipeName: recipeName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Full Sync
|
|
||||||
|
|
||||||
func performSync() async {
|
|
||||||
guard let appState, let mealPlanManager else { return }
|
|
||||||
|
|
||||||
mealPlanManager.syncStartTime = MealPlanDate.now()
|
|
||||||
defer { mealPlanManager.syncStartTime = nil }
|
|
||||||
|
|
||||||
// Phase 1: Push locally-known meal plan state
|
|
||||||
let localRecipeIds = Array(Set(
|
|
||||||
mealPlanManager.entriesByDate.values.flatMap { $0 }.map(\.recipeId)
|
|
||||||
))
|
|
||||||
for recipeId in localRecipeIds {
|
|
||||||
await pushMealPlanState(forRecipeId: recipeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Discover meal plan assignments from server
|
|
||||||
let allRecipes = await appState.getRecipes()
|
|
||||||
let lastSync = UserSettings.shared.lastMealPlanSyncDate
|
|
||||||
|
|
||||||
// Filter to recipes modified since last sync
|
|
||||||
let recipesToCheck: [Recipe]
|
|
||||||
if let lastSync {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
||||||
recipesToCheck = allRecipes.filter { recipe in
|
|
||||||
guard let dateStr = recipe.dateModified,
|
|
||||||
let date = formatter.date(from: dateStr) else { return true }
|
|
||||||
return date > lastSync
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
recipesToCheck = allRecipes // First sync: check all
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch details concurrently (max 5 parallel)
|
|
||||||
await withTaskGroup(of: (String, String, MealPlanAssignment?)?.self) { group in
|
|
||||||
var iterator = recipesToCheck.makeIterator()
|
|
||||||
let maxConcurrent = 5
|
|
||||||
var active = 0
|
|
||||||
|
|
||||||
while active < maxConcurrent, let recipe = iterator.next() {
|
|
||||||
active += 1
|
|
||||||
group.addTask {
|
|
||||||
guard let detail = await appState.getRecipe(
|
|
||||||
id: recipe.recipe_id, fetchMode: .onlyServer
|
|
||||||
) else { return nil }
|
|
||||||
return (String(recipe.recipe_id), detail.name, detail.mealPlanAssignment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await result in group {
|
|
||||||
if let (recipeId, recipeName, assignment) = result,
|
|
||||||
let assignment, !assignment.dates.isEmpty {
|
|
||||||
mealPlanManager.reconcileFromServer(
|
|
||||||
serverAssignment: assignment,
|
|
||||||
recipeId: recipeId,
|
|
||||||
recipeName: recipeName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if let recipe = iterator.next() {
|
|
||||||
group.addTask {
|
|
||||||
guard let detail = await appState.getRecipe(
|
|
||||||
id: recipe.recipe_id, fetchMode: .onlyServer
|
|
||||||
) else { return nil }
|
|
||||||
return (String(recipe.recipe_id), detail.name, detail.mealPlanAssignment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UserSettings.shared.lastMealPlanSyncDate = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Merge Logic
|
|
||||||
|
|
||||||
private func mergeAssignments(local: MealPlanAssignment?, server: MealPlanAssignment?) -> MealPlanAssignment {
|
|
||||||
guard let local else { return server ?? MealPlanAssignment() }
|
|
||||||
guard let server else { return local }
|
|
||||||
|
|
||||||
var merged = local.dates
|
|
||||||
for (dayStr, serverEntry) in server.dates {
|
|
||||||
if let localEntry = merged[dayStr] {
|
|
||||||
let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast
|
|
||||||
let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast
|
|
||||||
if serverDate > localDate {
|
|
||||||
merged[dayStr] = serverEntry
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
merged[dayStr] = serverEntry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prune all date entries older than 30 days
|
|
||||||
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))!
|
|
||||||
merged = merged.filter { dayStr, _ in
|
|
||||||
guard let date = MealPlanDate.dateFromDay(dayStr) else { return true }
|
|
||||||
return date >= cutoff
|
|
||||||
}
|
|
||||||
|
|
||||||
return MealPlanAssignment(
|
|
||||||
lastModified: MealPlanDate.now(),
|
|
||||||
dates: merged
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,28 +6,174 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
class ObservableRecipeDetail: ObservableObject {
|
// MARK: - Recipe Model
|
||||||
|
|
||||||
|
@Model
|
||||||
|
class RecipeImage {
|
||||||
|
enum RecipeImageSize {
|
||||||
|
case THUMB, FULL
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageData: Data?
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
var image: UIImage? {
|
||||||
|
guard let imageData else { return nil }
|
||||||
|
return UIImage(data: imageData)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(imageData: Data? = nil) {
|
||||||
|
self.imageData = imageData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model
|
||||||
|
class RecipeThumbnail {
|
||||||
|
var thumbnailData: Data?
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
var thumbnail: UIImage? {
|
||||||
|
guard let thumbnailData else { return nil }
|
||||||
|
return UIImage(data: thumbnailData)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(thumbnailData: Data? = nil) {
|
||||||
|
self.thumbnailData = thumbnailData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model
|
||||||
|
class Recipe {
|
||||||
|
var id: String
|
||||||
|
var name: String
|
||||||
|
var keywords: [String]
|
||||||
|
@Attribute(.externalStorage) var image: RecipeImage?
|
||||||
|
@Attribute(.externalStorage) var thumbnail: RecipeThumbnail?
|
||||||
|
var dateCreated: String? = nil
|
||||||
|
var dateModified: String? = nil
|
||||||
|
var prepTime: String
|
||||||
|
var cookTime: String
|
||||||
|
var totalTime: String
|
||||||
|
var recipeDescription: String
|
||||||
|
var url: String?
|
||||||
|
var yield: Int
|
||||||
|
var category: String
|
||||||
|
var tools: [String]
|
||||||
|
var ingredients: [String]
|
||||||
|
var instructions: [String]
|
||||||
|
var nutrition: [String:String]
|
||||||
|
|
||||||
|
// Additional functionality
|
||||||
|
@Transient
|
||||||
|
var ingredientMultiplier: Double = 1.0
|
||||||
|
|
||||||
|
var prepTimeDurationComponent: DurationComponents {
|
||||||
|
DurationComponents.fromPTString(prepTime)
|
||||||
|
}
|
||||||
|
var cookTimeDurationComponent: DurationComponents {
|
||||||
|
DurationComponents.fromPTString(cookTime)
|
||||||
|
}
|
||||||
|
var totalTimeDurationComponent: DurationComponents {
|
||||||
|
DurationComponents.fromPTString(totalTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
keywords: [String],
|
||||||
|
dateCreated: String? = nil,
|
||||||
|
dateModified: String? = nil,
|
||||||
|
prepTime: String,
|
||||||
|
cookTime: String,
|
||||||
|
totalTime: String,
|
||||||
|
recipeDescription: String,
|
||||||
|
url: String? = nil,
|
||||||
|
yield: Int,
|
||||||
|
category: String,
|
||||||
|
tools: [String],
|
||||||
|
ingredients: [String],
|
||||||
|
instructions: [String],
|
||||||
|
nutrition: [String : String],
|
||||||
|
ingredientMultiplier: Double
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.keywords = keywords
|
||||||
|
self.dateCreated = dateCreated
|
||||||
|
self.dateModified = dateModified
|
||||||
|
self.prepTime = prepTime
|
||||||
|
self.cookTime = cookTime
|
||||||
|
self.totalTime = totalTime
|
||||||
|
self.recipeDescription = recipeDescription
|
||||||
|
self.url = url
|
||||||
|
self.yield = yield
|
||||||
|
self.category = category
|
||||||
|
self.tools = tools
|
||||||
|
self.ingredients = ingredients
|
||||||
|
self.instructions = instructions
|
||||||
|
self.nutrition = nutrition
|
||||||
|
self.ingredientMultiplier = ingredientMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.name = String(localized: "New Recipe")
|
||||||
|
self.keywords = []
|
||||||
|
self.dateCreated = nil
|
||||||
|
self.dateModified = nil
|
||||||
|
self.prepTime = "0"
|
||||||
|
self.cookTime = "0"
|
||||||
|
self.totalTime = "0"
|
||||||
|
self.recipeDescription = ""
|
||||||
|
self.url = ""
|
||||||
|
self.yield = 1
|
||||||
|
self.category = ""
|
||||||
|
self.tools = []
|
||||||
|
self.ingredients = []
|
||||||
|
self.instructions = []
|
||||||
|
self.nutrition = [:]
|
||||||
|
self.ingredientMultiplier = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// MARK: - Recipe Stub
|
||||||
|
|
||||||
|
struct RecipeStub: Codable, Hashable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let keywords: String?
|
||||||
|
let dateCreated: String?
|
||||||
|
let dateModified: String?
|
||||||
|
let thumbnailPath: String?
|
||||||
|
var storedLocally: Bool = false
|
||||||
|
var lastUpdated: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recipe
|
||||||
|
/*
|
||||||
|
class Recipe: ObservableObject {
|
||||||
// Cookbook recipe detail fields
|
// Cookbook recipe detail fields
|
||||||
var id: String
|
var id: String
|
||||||
@Published var name: String
|
@Published var name: String
|
||||||
@Published var keywords: [String]
|
@Published var keywords: [String]
|
||||||
@Published var imageUrl: String
|
@Published var imageUrl: String?
|
||||||
|
var dateCreated: String? = nil
|
||||||
|
var dateModified: String? = nil
|
||||||
@Published var prepTime: DurationComponents
|
@Published var prepTime: DurationComponents
|
||||||
@Published var cookTime: DurationComponents
|
@Published var cookTime: DurationComponents
|
||||||
@Published var totalTime: DurationComponents
|
@Published var totalTime: DurationComponents
|
||||||
@Published var description: String
|
@Published var description: String
|
||||||
@Published var url: String
|
@Published var url: String?
|
||||||
@Published var recipeYield: Int
|
@Published var recipeYield: Int
|
||||||
@Published var recipeCategory: String
|
@Published var recipeCategory: String
|
||||||
@Published var tool: [String]
|
@Published var tool: [String]
|
||||||
@Published var recipeIngredient: [String]
|
@Published var recipeIngredient: [String]
|
||||||
@Published var recipeInstructions: [String]
|
@Published var recipeInstructions: [String]
|
||||||
@Published var nutrition: [String:String]
|
@Published var nutrition: [String:String]
|
||||||
var groceryState: GroceryState?
|
|
||||||
var mealPlanAssignment: MealPlanAssignment?
|
|
||||||
|
|
||||||
// Additional functionality
|
// Additional functionality
|
||||||
@Published var ingredientMultiplier: Double
|
@Published var ingredientMultiplier: Double
|
||||||
@@ -45,18 +191,16 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
description = ""
|
description = ""
|
||||||
url = ""
|
url = ""
|
||||||
recipeYield = 1
|
recipeYield = 1
|
||||||
recipeCategory = "*"
|
recipeCategory = ""
|
||||||
tool = []
|
tool = []
|
||||||
recipeIngredient = []
|
recipeIngredient = []
|
||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
groceryState = nil
|
|
||||||
mealPlanAssignment = nil
|
|
||||||
|
|
||||||
ingredientMultiplier = 1
|
ingredientMultiplier = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ recipeDetail: RecipeDetail) {
|
init(_ recipeDetail: CookbookApiRecipeDetailV1) {
|
||||||
id = recipeDetail.id
|
id = recipeDetail.id
|
||||||
name = recipeDetail.name
|
name = recipeDetail.name
|
||||||
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
|
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
|
||||||
@@ -67,19 +211,79 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
description = recipeDetail.description
|
description = recipeDetail.description
|
||||||
url = recipeDetail.url ?? ""
|
url = recipeDetail.url ?? ""
|
||||||
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
|
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
|
||||||
recipeCategory = recipeDetail.recipeCategory.isEmpty ? "*" : recipeDetail.recipeCategory
|
recipeCategory = recipeDetail.recipeCategory
|
||||||
tool = recipeDetail.tool
|
tool = recipeDetail.tool
|
||||||
recipeIngredient = recipeDetail.recipeIngredient
|
recipeIngredient = recipeDetail.recipeIngredient
|
||||||
recipeInstructions = recipeDetail.recipeInstructions
|
recipeInstructions = recipeDetail.recipeInstructions
|
||||||
nutrition = recipeDetail.nutrition
|
nutrition = recipeDetail.nutrition
|
||||||
groceryState = recipeDetail.groceryState
|
|
||||||
mealPlanAssignment = recipeDetail.mealPlanAssignment
|
|
||||||
|
|
||||||
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toRecipeDetail() -> RecipeDetail {
|
init(
|
||||||
return RecipeDetail(
|
name: String,
|
||||||
|
keywords: [String],
|
||||||
|
dateCreated: String?,
|
||||||
|
dateModified: String?,
|
||||||
|
imageUrl: String?,
|
||||||
|
id: String,
|
||||||
|
prepTime: DurationComponents? = nil,
|
||||||
|
cookTime: DurationComponents? = nil,
|
||||||
|
totalTime: DurationComponents? = nil,
|
||||||
|
description: String,
|
||||||
|
url: String?,
|
||||||
|
recipeYield: Int,
|
||||||
|
recipeCategory: String,
|
||||||
|
tool: [String],
|
||||||
|
recipeIngredient: [String],
|
||||||
|
recipeInstructions: [String],
|
||||||
|
nutrition: [String:String]
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.keywords = keywords
|
||||||
|
self.dateCreated = dateCreated
|
||||||
|
self.dateModified = dateModified
|
||||||
|
self.imageUrl = imageUrl
|
||||||
|
self.id = id
|
||||||
|
self.prepTime = prepTime ?? DurationComponents()
|
||||||
|
self.cookTime = cookTime ?? DurationComponents()
|
||||||
|
self.totalTime = totalTime ?? DurationComponents()
|
||||||
|
self.description = description
|
||||||
|
self.url = url
|
||||||
|
self.recipeYield = recipeYield
|
||||||
|
self.recipeCategory = recipeCategory
|
||||||
|
self.tool = tool
|
||||||
|
self.recipeIngredient = recipeIngredient
|
||||||
|
self.recipeInstructions = recipeInstructions
|
||||||
|
self.nutrition = nutrition
|
||||||
|
|
||||||
|
ingredientMultiplier = Double(recipeYield == 0 ? 1 : recipeYield)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decode(String.self, forKey: .id)
|
||||||
|
_name = Published(initialValue: try container.decode(String.self, forKey: .name))
|
||||||
|
_keywords = Published(initialValue: try container.decode([String].self, forKey: .keywords))
|
||||||
|
_imageUrl = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .imageUrl))
|
||||||
|
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
|
||||||
|
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
||||||
|
_prepTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .prepTime))
|
||||||
|
_cookTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .cookTime))
|
||||||
|
_totalTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .totalTime))
|
||||||
|
_description = Published(initialValue: try container.decode(String.self, forKey: .description))
|
||||||
|
_url = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .url))
|
||||||
|
_recipeYield = Published(initialValue: try container.decode(Int.self, forKey: .recipeYield))
|
||||||
|
_recipeCategory = Published(initialValue: try container.decode(String.self, forKey: .recipeCategory))
|
||||||
|
_tool = Published(initialValue: try container.decode([String].self, forKey: .tool))
|
||||||
|
_recipeIngredient = Published(initialValue: try container.decode([String].self, forKey: .recipeIngredient))
|
||||||
|
_recipeInstructions = Published(initialValue: try container.decode([String].self, forKey: .recipeInstructions))
|
||||||
|
_nutrition = Published(initialValue: try container.decode([String: String].self, forKey: .nutrition))
|
||||||
|
_ingredientMultiplier = Published(initialValue: try container.decode(Double.self, forKey: .ingredientMultiplier))
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRecipeDetail() -> CookbookApiRecipeDetailV1 {
|
||||||
|
return CookbookApiRecipeDetailV1(
|
||||||
name: self.name,
|
name: self.name,
|
||||||
keywords: self.keywords.joined(separator: ","),
|
keywords: self.keywords.joined(separator: ","),
|
||||||
dateCreated: "",
|
dateCreated: "",
|
||||||
@@ -92,13 +296,11 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
description: self.description,
|
description: self.description,
|
||||||
url: self.url,
|
url: self.url,
|
||||||
recipeYield: self.recipeYield,
|
recipeYield: self.recipeYield,
|
||||||
recipeCategory: self.recipeCategory == "*" ? "" : self.recipeCategory,
|
recipeCategory: self.recipeCategory,
|
||||||
tool: self.tool,
|
tool: self.tool,
|
||||||
recipeIngredient: self.recipeIngredient,
|
recipeIngredient: self.recipeIngredient,
|
||||||
recipeInstructions: self.recipeInstructions,
|
recipeInstructions: self.recipeInstructions,
|
||||||
nutrition: self.nutrition,
|
nutrition: self.nutrition
|
||||||
groceryState: self.groceryState,
|
|
||||||
mealPlanAssignment: self.mealPlanAssignment
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,14 +309,14 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
return AttributedString(ingredient)
|
return AttributedString(ingredient)
|
||||||
}
|
}
|
||||||
// Match mixed fractions first
|
// Match mixed fractions first
|
||||||
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
|
var matches = Recipe.matchPatternAndMultiply(
|
||||||
.mixedFraction,
|
.mixedFraction,
|
||||||
in: ingredient,
|
in: ingredient,
|
||||||
multFactor: factor
|
multFactor: factor
|
||||||
)
|
)
|
||||||
// Then match fractions, exclude mixed fraction ranges
|
// Then match fractions, exclude mixed fraction ranges
|
||||||
matches.append(contentsOf:
|
matches.append(contentsOf:
|
||||||
ObservableRecipeDetail.matchPatternAndMultiply(
|
Recipe.matchPatternAndMultiply(
|
||||||
.fraction,
|
.fraction,
|
||||||
in: ingredient,
|
in: ingredient,
|
||||||
multFactor: factor,
|
multFactor: factor,
|
||||||
@@ -123,7 +325,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
)
|
)
|
||||||
// Match numbers at last, exclude all prior matches
|
// Match numbers at last, exclude all prior matches
|
||||||
matches.append(contentsOf:
|
matches.append(contentsOf:
|
||||||
ObservableRecipeDetail.matchPatternAndMultiply(
|
Recipe.matchPatternAndMultiply(
|
||||||
.number,
|
.number,
|
||||||
in: ingredient,
|
in: ingredient,
|
||||||
multFactor: factor,
|
multFactor: factor,
|
||||||
@@ -189,7 +391,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
}
|
}
|
||||||
return foundMatches
|
return foundMatches
|
||||||
} catch {
|
} catch {
|
||||||
Logger.data.error("Regex error: \(error.localizedDescription)")
|
print("Regex error: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -230,6 +432,34 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Recipe: Codable {
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, keywords, imageUrl, dateCreated, dateModified, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition, ingredientMultiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(keywords, forKey: .keywords)
|
||||||
|
try container.encode(imageUrl, forKey: .imageUrl)
|
||||||
|
try container.encode(dateCreated, forKey: .dateCreated)
|
||||||
|
try container.encode(dateModified, forKey: .dateModified)
|
||||||
|
try container.encode(prepTime, forKey: .prepTime)
|
||||||
|
try container.encode(cookTime, forKey: .cookTime)
|
||||||
|
try container.encode(totalTime, forKey: .totalTime)
|
||||||
|
try container.encode(description, forKey: .description)
|
||||||
|
try container.encode(url, forKey: .url)
|
||||||
|
try container.encode(recipeYield, forKey: .recipeYield)
|
||||||
|
try container.encode(recipeCategory, forKey: .recipeCategory)
|
||||||
|
try container.encode(tool, forKey: .tool)
|
||||||
|
try container.encode(recipeIngredient, forKey: .recipeIngredient)
|
||||||
|
try container.encode(recipeInstructions, forKey: .recipeInstructions)
|
||||||
|
try container.encode(nutrition, forKey: .nutrition)
|
||||||
|
try container.encode(ingredientMultiplier, forKey: .ingredientMultiplier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum RegexPattern: String, CaseIterable, Identifiable {
|
enum RegexPattern: String, CaseIterable, Identifiable {
|
||||||
case mixedFraction, fraction, number
|
case mixedFraction, fraction, number
|
||||||
|
|
||||||
@@ -258,3 +488,4 @@ enum RegexPattern: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
//
|
|
||||||
// RemindersGroceryStore.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import EventKit
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
/// Maps a reminder's calendarItemIdentifier to its recipe context.
|
|
||||||
struct ReminderMapping: Codable {
|
|
||||||
let reminderIdentifier: String
|
|
||||||
let recipeId: String
|
|
||||||
let recipeName: String
|
|
||||||
let itemName: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persisted mapping file: keyed by recipeId, each holding an array of reminder mappings.
|
|
||||||
struct ReminderMappingStore: Codable {
|
|
||||||
var recipes: [String: RecipeMappingEntry] = [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RecipeMappingEntry: Codable {
|
|
||||||
let recipeName: String
|
|
||||||
var mappings: [ReminderMapping]
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
class RemindersGroceryStore {
|
|
||||||
private let eventStore = EKEventStore()
|
|
||||||
private(set) var groceryDict: [String: GroceryRecipe] = [:]
|
|
||||||
var onDataChanged: (() -> Void)?
|
|
||||||
|
|
||||||
private let dataStore = DataStore()
|
|
||||||
private let mappingPath = "reminder_mappings.data"
|
|
||||||
private var mappingStore = ReminderMappingStore()
|
|
||||||
|
|
||||||
/// When true, the next `EKEventStoreChanged` notification is skipped because
|
|
||||||
/// it was triggered by our own save. Prevents a race where `load()` reads stale
|
|
||||||
/// mapping data from disk before `saveMappings()` finishes writing.
|
|
||||||
private var ignoreNextExternalChange = false
|
|
||||||
|
|
||||||
init() {
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
forName: .EKEventStoreChanged,
|
|
||||||
object: eventStore,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
|
||||||
if self.ignoreNextExternalChange {
|
|
||||||
self.ignoreNextExternalChange = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await self.load()
|
|
||||||
self.onDataChanged?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self, name: .EKEventStoreChanged, object: eventStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Permission
|
|
||||||
|
|
||||||
func checkPermissionStatus() -> EKAuthorizationStatus {
|
|
||||||
EKEventStore.authorizationStatus(for: .reminder)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestAccess() async -> Bool {
|
|
||||||
do {
|
|
||||||
return try await eventStore.requestFullAccessToReminders()
|
|
||||||
} catch {
|
|
||||||
Logger.view.error("Failed to request Reminders access: \(error.localizedDescription)")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Lists
|
|
||||||
|
|
||||||
func availableReminderLists() -> [EKCalendar] {
|
|
||||||
eventStore.calendars(for: .reminder)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func targetCalendar() -> EKCalendar? {
|
|
||||||
let identifier = UserSettings.shared.remindersListIdentifier
|
|
||||||
if !identifier.isEmpty,
|
|
||||||
let calendar = eventStore.calendar(withIdentifier: identifier) {
|
|
||||||
return calendar
|
|
||||||
}
|
|
||||||
return eventStore.defaultCalendarForNewReminders()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch Helper
|
|
||||||
|
|
||||||
private nonisolated func fetchReminders(matching predicate: NSPredicate, in store: EKEventStore) async -> [EKReminder] {
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
store.fetchReminders(matching: predicate) { reminders in
|
|
||||||
continuation.resume(returning: reminders ?? [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - CRUD
|
|
||||||
|
|
||||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
|
|
||||||
guard let calendar = targetCalendar() else {
|
|
||||||
Logger.view.error("No target Reminders calendar available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let reminder = EKReminder(eventStore: eventStore)
|
|
||||||
reminder.title = itemName
|
|
||||||
reminder.calendar = calendar
|
|
||||||
do {
|
|
||||||
ignoreNextExternalChange = true
|
|
||||||
try eventStore.save(reminder, commit: true)
|
|
||||||
let name = recipeName ?? "-"
|
|
||||||
addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: itemName)
|
|
||||||
appendToCache(itemName: itemName, recipeId: recipeId, recipeName: name)
|
|
||||||
} catch {
|
|
||||||
ignoreNextExternalChange = false
|
|
||||||
Logger.view.error("Failed to save reminder: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
|
||||||
guard let calendar = targetCalendar() else {
|
|
||||||
Logger.view.error("No target Reminders calendar available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let name = recipeName ?? "-"
|
|
||||||
for item in items {
|
|
||||||
let reminder = EKReminder(eventStore: eventStore)
|
|
||||||
reminder.title = item
|
|
||||||
reminder.calendar = calendar
|
|
||||||
do {
|
|
||||||
try eventStore.save(reminder, commit: false)
|
|
||||||
addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: item)
|
|
||||||
appendToCache(itemName: item, recipeId: recipeId, recipeName: name)
|
|
||||||
} catch {
|
|
||||||
Logger.view.error("Failed to save reminder: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
ignoreNextExternalChange = true
|
|
||||||
try eventStore.commit()
|
|
||||||
} catch {
|
|
||||||
ignoreNextExternalChange = false
|
|
||||||
Logger.view.error("Failed to commit reminders: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
saveMappings()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
|
||||||
// Find the reminder identifier from our mapping
|
|
||||||
guard let entry = mappingStore.recipes[recipeId],
|
|
||||||
let mapping = entry.mappings.first(where: { $0.itemName == itemName }) else { return }
|
|
||||||
let identifier = mapping.reminderIdentifier
|
|
||||||
|
|
||||||
// Find and remove the actual reminder
|
|
||||||
guard let calendar = targetCalendar() else { return }
|
|
||||||
let predicate = eventStore.predicateForReminders(in: [calendar])
|
|
||||||
let store = eventStore
|
|
||||||
Task {
|
|
||||||
let reminders = await fetchReminders(matching: predicate, in: store)
|
|
||||||
for reminder in reminders where reminder.calendarItemIdentifier == identifier {
|
|
||||||
do {
|
|
||||||
self.ignoreNextExternalChange = true
|
|
||||||
try self.eventStore.remove(reminder, commit: true)
|
|
||||||
} catch {
|
|
||||||
self.ignoreNextExternalChange = false
|
|
||||||
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
self.removeMapping(reminderIdentifier: identifier, recipeId: recipeId)
|
|
||||||
self.removeFromCache(itemName: itemName, recipeId: recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteGroceryRecipe(_ recipeId: String) {
|
|
||||||
guard let entry = mappingStore.recipes[recipeId] else { return }
|
|
||||||
let identifiers = Set(entry.mappings.map(\.reminderIdentifier))
|
|
||||||
|
|
||||||
guard let calendar = targetCalendar() else { return }
|
|
||||||
let predicate = eventStore.predicateForReminders(in: [calendar])
|
|
||||||
let store = eventStore
|
|
||||||
Task {
|
|
||||||
let reminders = await fetchReminders(matching: predicate, in: store)
|
|
||||||
for reminder in reminders where identifiers.contains(reminder.calendarItemIdentifier) {
|
|
||||||
do {
|
|
||||||
try self.eventStore.remove(reminder, commit: false)
|
|
||||||
} catch {
|
|
||||||
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
self.ignoreNextExternalChange = true
|
|
||||||
try self.eventStore.commit()
|
|
||||||
} catch {
|
|
||||||
self.ignoreNextExternalChange = false
|
|
||||||
Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
self.mappingStore.recipes.removeValue(forKey: recipeId)
|
|
||||||
self.saveMappings()
|
|
||||||
self.groceryDict.removeValue(forKey: recipeId)
|
|
||||||
self.onDataChanged?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteAll() {
|
|
||||||
let allIdentifiers = Set(mappingStore.recipes.values.flatMap { $0.mappings.map(\.reminderIdentifier) })
|
|
||||||
guard !allIdentifiers.isEmpty else { return }
|
|
||||||
|
|
||||||
guard let calendar = targetCalendar() else { return }
|
|
||||||
let predicate = eventStore.predicateForReminders(in: [calendar])
|
|
||||||
let store = eventStore
|
|
||||||
Task {
|
|
||||||
let reminders = await fetchReminders(matching: predicate, in: store)
|
|
||||||
for reminder in reminders where allIdentifiers.contains(reminder.calendarItemIdentifier) {
|
|
||||||
do {
|
|
||||||
try self.eventStore.remove(reminder, commit: false)
|
|
||||||
} catch {
|
|
||||||
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
self.ignoreNextExternalChange = true
|
|
||||||
try self.eventStore.commit()
|
|
||||||
} catch {
|
|
||||||
self.ignoreNextExternalChange = false
|
|
||||||
Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
self.mappingStore.recipes = [:]
|
|
||||||
self.saveMappings()
|
|
||||||
self.groceryDict = [:]
|
|
||||||
self.onDataChanged?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsItem(at recipeId: String, item: String) -> Bool {
|
|
||||||
guard let recipe = groceryDict[recipeId] else { return false }
|
|
||||||
return recipe.items.contains(where: { $0.name == item })
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsRecipe(_ recipeId: String) -> Bool {
|
|
||||||
groceryDict[recipeId] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Load / Sync
|
|
||||||
|
|
||||||
func load() async {
|
|
||||||
// Load the local mapping first
|
|
||||||
if let stored: ReminderMappingStore = try? await dataStore.load(fromPath: mappingPath) {
|
|
||||||
mappingStore = stored
|
|
||||||
}
|
|
||||||
|
|
||||||
guard checkPermissionStatus() == .fullAccess else { return }
|
|
||||||
guard let calendar = targetCalendar() else { return }
|
|
||||||
let predicate = eventStore.predicateForReminders(in: [calendar])
|
|
||||||
let store = eventStore
|
|
||||||
let reminders = await fetchReminders(matching: predicate, in: store)
|
|
||||||
|
|
||||||
// Build a set of live reminder identifiers for cleanup
|
|
||||||
let liveIdentifiers = Set(reminders.map(\.calendarItemIdentifier))
|
|
||||||
|
|
||||||
// Prune mappings for reminders that no longer exist (deleted externally)
|
|
||||||
var pruned = false
|
|
||||||
for (recipeId, entry) in mappingStore.recipes {
|
|
||||||
let before = entry.mappings.count
|
|
||||||
let filtered = entry.mappings.filter { liveIdentifiers.contains($0.reminderIdentifier) }
|
|
||||||
if filtered.isEmpty {
|
|
||||||
mappingStore.recipes.removeValue(forKey: recipeId)
|
|
||||||
pruned = true
|
|
||||||
} else if filtered.count != before {
|
|
||||||
mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: entry.recipeName, mappings: filtered)
|
|
||||||
pruned = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pruned {
|
|
||||||
saveMappings()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild groceryDict from mappings (source of truth for grouping)
|
|
||||||
var dict: [String: GroceryRecipe] = [:]
|
|
||||||
for (recipeId, entry) in mappingStore.recipes {
|
|
||||||
let items = entry.mappings.map { GroceryRecipeItem($0.itemName) }
|
|
||||||
dict[recipeId] = GroceryRecipe(name: entry.recipeName, items: items)
|
|
||||||
}
|
|
||||||
groceryDict = dict
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mapping Persistence
|
|
||||||
|
|
||||||
private func addMapping(reminderIdentifier: String, recipeId: String, recipeName: String, itemName: String) {
|
|
||||||
let mapping = ReminderMapping(
|
|
||||||
reminderIdentifier: reminderIdentifier,
|
|
||||||
recipeId: recipeId,
|
|
||||||
recipeName: recipeName,
|
|
||||||
itemName: itemName
|
|
||||||
)
|
|
||||||
if mappingStore.recipes[recipeId] != nil {
|
|
||||||
mappingStore.recipes[recipeId]?.mappings.append(mapping)
|
|
||||||
} else {
|
|
||||||
mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: recipeName, mappings: [mapping])
|
|
||||||
}
|
|
||||||
saveMappings()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeMapping(reminderIdentifier: String, recipeId: String) {
|
|
||||||
guard var entry = mappingStore.recipes[recipeId] else { return }
|
|
||||||
entry.mappings.removeAll { $0.reminderIdentifier == reminderIdentifier }
|
|
||||||
if entry.mappings.isEmpty {
|
|
||||||
mappingStore.recipes.removeValue(forKey: recipeId)
|
|
||||||
} else {
|
|
||||||
mappingStore.recipes[recipeId] = entry
|
|
||||||
}
|
|
||||||
saveMappings()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveMappings() {
|
|
||||||
Task {
|
|
||||||
await dataStore.save(data: mappingStore, toPath: mappingPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Cache Helpers
|
|
||||||
|
|
||||||
private func appendToCache(itemName: String, recipeId: String, recipeName: String) {
|
|
||||||
if groceryDict[recipeId] != nil {
|
|
||||||
groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
|
||||||
} else {
|
|
||||||
groceryDict[recipeId] = GroceryRecipe(name: recipeName, item: GroceryRecipeItem(itemName))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeFromCache(itemName: String, recipeId: String) {
|
|
||||||
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
|
||||||
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
|
||||||
if groceryDict[recipeId]?.items.isEmpty == true {
|
|
||||||
groceryDict.removeValue(forKey: recipeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -121,66 +121,6 @@ class UserSettings: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published var groceryListMode: String {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(groceryListMode, forKey: "groceryListMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var remindersListIdentifier: String {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(remindersListIdentifier, forKey: "remindersListIdentifier")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var grocerySyncEnabled: Bool {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var mealPlanSyncEnabled: Bool {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(mealPlanSyncEnabled, forKey: "mealPlanSyncEnabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var appearanceMode: String {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(appearanceMode, forKey: "appearanceMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var categorySortMode: String {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(categorySortMode, forKey: "categorySortMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var categorySortAscending: Bool {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(categorySortAscending, forKey: "categorySortAscending")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var recipeSortMode: String {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(recipeSortMode, forKey: "recipeSortMode")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var recipeSortAscending: Bool {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(recipeSortAscending, forKey: "recipeSortAscending")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published var lastMealPlanSyncDate: Date? {
|
|
||||||
didSet {
|
|
||||||
UserDefaults.standard.set(lastMealPlanSyncDate, forKey: "lastMealPlanSyncDate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||||
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
||||||
@@ -200,16 +140,6 @@ class UserSettings: ObservableObject {
|
|||||||
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
||||||
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
|
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
|
||||||
self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "."
|
self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "."
|
||||||
self.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue
|
|
||||||
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
|
|
||||||
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
|
|
||||||
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true
|
|
||||||
self.appearanceMode = UserDefaults.standard.object(forKey: "appearanceMode") as? String ?? AppearanceMode.system.rawValue
|
|
||||||
self.categorySortMode = UserDefaults.standard.object(forKey: "categorySortMode") as? String ?? CategorySortMode.recentlyUsed.rawValue
|
|
||||||
self.categorySortAscending = UserDefaults.standard.object(forKey: "categorySortAscending") as? Bool ?? true
|
|
||||||
self.recipeSortMode = UserDefaults.standard.object(forKey: "recipeSortMode") as? String ?? RecipeSortMode.recentlyAdded.rawValue
|
|
||||||
self.recipeSortAscending = UserDefaults.standard.object(forKey: "recipeSortAscending") as? Bool ?? true
|
|
||||||
self.lastMealPlanSyncDate = UserDefaults.standard.object(forKey: "lastMealPlanSyncDate") as? Date
|
|
||||||
|
|
||||||
if authString == "" {
|
if authString == "" {
|
||||||
if token != "" && username != "" {
|
if token != "" && username != "" {
|
||||||
|
|||||||
@@ -6,15 +6,14 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
|
|
||||||
extension JSONDecoder {
|
extension JSONDecoder {
|
||||||
static func safeDecode<T: Decodable>(_ data: Data) -> T? {
|
static func safeDecode<T: Decodable>(_ data: Data) -> T? {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
do {
|
do {
|
||||||
return try decoder.decode(T.self, from: data)
|
return try decoder.decode(T.self, from: data)
|
||||||
} catch {
|
} catch (let error) {
|
||||||
Logger.data.error("JSONDecoder - safeDecode(): \(error.localizedDescription)")
|
print(error)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +24,7 @@ extension JSONEncoder {
|
|||||||
do {
|
do {
|
||||||
return try JSONEncoder().encode(object)
|
return try JSONEncoder().encode(object)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.data.error("JSONEncoder - safeEncode(): Could not encode \(String(describing: T.self))")
|
print("JSONDecoder - safeEncode(): Could not encode object \(T.self)")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,4 @@ extension Logger {
|
|||||||
|
|
||||||
/// Network related logging
|
/// Network related logging
|
||||||
static let network = Logger(subsystem: subsystem, category: "network")
|
static let network = Logger(subsystem: subsystem, category: "network")
|
||||||
|
|
||||||
/// Data/persistence related logging
|
|
||||||
static let data = Logger(subsystem: subsystem, category: "data")
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
138
Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift
Normal file
138
Nextcloud Cookbook iOS Client/Models/RecipeEditViewModel.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// RecipeEditViewModel.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 11.11.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
/*
|
||||||
|
@MainActor class RecipeEditViewModel: ObservableObject {
|
||||||
|
@ObservedObject var mainViewModel: AppState
|
||||||
|
@Published var recipe: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1()
|
||||||
|
|
||||||
|
@Published var prepDuration: DurationComponents = DurationComponents()
|
||||||
|
@Published var cookDuration: DurationComponents = DurationComponents()
|
||||||
|
@Published var totalDuration: DurationComponents = DurationComponents()
|
||||||
|
|
||||||
|
@Published var searchText: String = ""
|
||||||
|
@Published var keywords: [String] = []
|
||||||
|
@Published var keywordSuggestions: [RecipeKeyword] = []
|
||||||
|
|
||||||
|
@Published var showImportSection: Bool = false
|
||||||
|
@Published var importURL: String = ""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var uploadNew: Bool = true
|
||||||
|
var waitingForUpload: Bool = false
|
||||||
|
|
||||||
|
|
||||||
|
init(mainViewModel: AppState, uploadNew: Bool) {
|
||||||
|
self.mainViewModel = mainViewModel
|
||||||
|
self.uploadNew = uploadNew
|
||||||
|
}
|
||||||
|
|
||||||
|
init(mainViewModel: AppState, recipeDetail: CookbookApiRecipeDetailV1, uploadNew: Bool) {
|
||||||
|
self.mainViewModel = mainViewModel
|
||||||
|
self.recipe = recipeDetail
|
||||||
|
self.uploadNew = uploadNew
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func createRecipe() {
|
||||||
|
self.recipe.prepTime = prepDuration.toPTString()
|
||||||
|
self.recipe.cookTime = cookDuration.toPTString()
|
||||||
|
self.recipe.totalTime = totalDuration.toPTString()
|
||||||
|
self.recipe.setKeywordsFromArray(keywords)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recipeValid() -> RecipeAlert? {
|
||||||
|
// Check if the recipe has a name
|
||||||
|
if recipe.name.replacingOccurrences(of: " ", with: "") == "" {
|
||||||
|
return RecipeAlert.NO_TITLE
|
||||||
|
}
|
||||||
|
// Check if the recipe has a unique name
|
||||||
|
for recipeList in mainViewModel.recipes.values {
|
||||||
|
for r in recipeList {
|
||||||
|
if r.name
|
||||||
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
.lowercased() ==
|
||||||
|
recipe.name
|
||||||
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
.lowercased()
|
||||||
|
{
|
||||||
|
return RecipeAlert.DUPLICATE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadNewRecipe() async -> UserAlert? {
|
||||||
|
print("Uploading new recipe.")
|
||||||
|
waitingForUpload = true
|
||||||
|
createRecipe()
|
||||||
|
if let recipeValidationError = recipeValid() {
|
||||||
|
return recipeValidationError
|
||||||
|
}
|
||||||
|
|
||||||
|
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadEditedRecipe() async -> UserAlert? {
|
||||||
|
waitingForUpload = true
|
||||||
|
print("Uploading changed recipe.")
|
||||||
|
guard let recipeId = Int(recipe.id) else { return RequestAlert.REQUEST_DROPPED }
|
||||||
|
createRecipe()
|
||||||
|
|
||||||
|
return await mainViewModel.uploadRecipe(recipeDetail: self.recipe, createNew: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRecipe() async -> RequestAlert? {
|
||||||
|
guard let id = Int(recipe.id) else {
|
||||||
|
return .REQUEST_DROPPED
|
||||||
|
}
|
||||||
|
return await mainViewModel.deleteRecipe(withId: id, categoryName: recipe.recipeCategory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareView() {
|
||||||
|
if let prepTime = recipe.prepTime {
|
||||||
|
prepDuration.fromPTString(prepTime)
|
||||||
|
}
|
||||||
|
if let cookTime = recipe.cookTime {
|
||||||
|
cookDuration.fromPTString(cookTime)
|
||||||
|
}
|
||||||
|
if let totalTime = recipe.totalTime {
|
||||||
|
totalDuration.fromPTString(totalTime)
|
||||||
|
}
|
||||||
|
self.keywords = recipe.getKeywordsArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
func importRecipe() async -> UserAlert? {
|
||||||
|
let (scrapedRecipe, error) = await mainViewModel.importRecipe(url: importURL)
|
||||||
|
if let scrapedRecipe = scrapedRecipe {
|
||||||
|
self.recipe = scrapedRecipe
|
||||||
|
prepareView()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: importURL)
|
||||||
|
if let scrapedRecipe = scrapedRecipe {
|
||||||
|
self.recipe = scrapedRecipe
|
||||||
|
prepareView()
|
||||||
|
}
|
||||||
|
if let error = error {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
//
|
|
||||||
// CookbookApi.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 13.11.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
|
|
||||||
/// The Cookbook API version.
|
|
||||||
enum CookbookApiVersion: String {
|
|
||||||
case v1 = "v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// A protocol defining common API endpoints for the Cookbook API.
|
|
||||||
protocol CookbookApiProtocol {
|
|
||||||
func importRecipe(url: String) async throws -> RecipeDetail
|
|
||||||
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage?
|
|
||||||
func getRecipes() async throws -> [Recipe]
|
|
||||||
func createRecipe(_ recipe: RecipeDetail) async throws -> Int
|
|
||||||
func getRecipe(id: Int) async throws -> RecipeDetail
|
|
||||||
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int
|
|
||||||
func deleteRecipe(id: Int) async throws
|
|
||||||
func getCategories() async throws -> [Category]
|
|
||||||
func getCategory(named: String) async throws -> [Recipe]
|
|
||||||
func renameCategory(named: String, to newName: String) async throws
|
|
||||||
func getTags() async throws -> [RecipeKeyword]
|
|
||||||
func getRecipesTagged(keyword: String) async throws -> [Recipe]
|
|
||||||
func searchRecipes(query: String) async throws -> [Recipe]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
enum CookbookApiFactory {
|
|
||||||
static func makeClient(
|
|
||||||
version: CookbookApiVersion = UserSettings.shared.cookbookApiVersion,
|
|
||||||
settings: UserSettings = .shared
|
|
||||||
) -> CookbookApiProtocol {
|
|
||||||
switch version {
|
|
||||||
case .v1:
|
|
||||||
return CookbookApiClient(settings: settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//
|
|
||||||
// CookbookApiV1.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 16.11.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
|
|
||||||
final class CookbookApiClient: CookbookApiProtocol {
|
|
||||||
private let basePath = "/index.php/apps/cookbook/api/v1"
|
|
||||||
private let settings: UserSettings
|
|
||||||
|
|
||||||
private struct RecipeImportRequest: Codable {
|
|
||||||
let url: String
|
|
||||||
}
|
|
||||||
|
|
||||||
init(settings: UserSettings = .shared) {
|
|
||||||
self.settings = settings
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private helpers
|
|
||||||
|
|
||||||
private var auth: String { settings.authString }
|
|
||||||
|
|
||||||
private func makeRequest(
|
|
||||||
path: String,
|
|
||||||
method: RequestMethod,
|
|
||||||
accept: ContentType = .JSON,
|
|
||||||
contentType: ContentType? = nil,
|
|
||||||
body: Data? = nil
|
|
||||||
) -> ApiRequest {
|
|
||||||
var headers = [
|
|
||||||
HeaderField.ocsRequest(value: true),
|
|
||||||
HeaderField.accept(value: accept)
|
|
||||||
]
|
|
||||||
if let contentType = contentType {
|
|
||||||
headers.append(HeaderField.contentType(value: contentType))
|
|
||||||
}
|
|
||||||
return ApiRequest(
|
|
||||||
path: basePath + path,
|
|
||||||
method: method,
|
|
||||||
authString: auth,
|
|
||||||
headerFields: headers,
|
|
||||||
body: body
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendAndDecode<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
if let error = error { throw error }
|
|
||||||
guard let data = data else { throw NetworkError.unknownError(detail: "No data received") }
|
|
||||||
guard let decoded: T = JSONDecoder.safeDecode(data) else {
|
|
||||||
throw NetworkError.decodingFailed(detail: "Failed to decode \(T.self)")
|
|
||||||
}
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendRaw(_ request: ApiRequest) async throws -> Data {
|
|
||||||
let (data, error) = await request.send()
|
|
||||||
if let error = error { throw error }
|
|
||||||
guard let data = data else { throw NetworkError.unknownError(detail: "No data received") }
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Protocol implementation
|
|
||||||
|
|
||||||
func importRecipe(url: String) async throws -> RecipeDetail {
|
|
||||||
let importRequest = RecipeImportRequest(url: url)
|
|
||||||
guard let body = JSONEncoder.safeEncode(importRequest) else {
|
|
||||||
throw NetworkError.encodingFailed(detail: "Failed to encode import request")
|
|
||||||
}
|
|
||||||
let request = makeRequest(path: "/import", method: .POST, contentType: .JSON, body: body)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getImage(id: Int, size: RecipeImage.RecipeImageSize) async throws -> UIImage? {
|
|
||||||
let imageSize = (size == .FULL ? "full" : "thumb")
|
|
||||||
let request = makeRequest(path: "/recipes/\(id)/image?size=\(imageSize)", method: .GET, accept: .IMAGE)
|
|
||||||
let data = try await sendRaw(request)
|
|
||||||
return UIImage(data: data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRecipes() async throws -> [Recipe] {
|
|
||||||
let request = makeRequest(path: "/recipes", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func createRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
|
||||||
guard let body = JSONEncoder.safeEncode(recipe) else {
|
|
||||||
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
|
||||||
}
|
|
||||||
let request = makeRequest(path: "/recipes", method: .POST, contentType: .JSON, body: body)
|
|
||||||
let data = try await sendRaw(request)
|
|
||||||
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
|
||||||
if let id = json as? Int {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRecipe(id: Int) async throws -> RecipeDetail {
|
|
||||||
let request = makeRequest(path: "/recipes/\(id)", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateRecipe(_ recipe: RecipeDetail) async throws -> Int {
|
|
||||||
guard let body = JSONEncoder.safeEncode(recipe) else {
|
|
||||||
throw NetworkError.encodingFailed(detail: "Failed to encode recipe")
|
|
||||||
}
|
|
||||||
let request = makeRequest(path: "/recipes/\(recipe.id)", method: .PUT, contentType: .JSON, body: body)
|
|
||||||
let data = try await sendRaw(request)
|
|
||||||
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
|
||||||
if let id = json as? Int {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
throw NetworkError.decodingFailed(detail: "Expected recipe ID in response")
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteRecipe(id: Int) async throws {
|
|
||||||
let request = makeRequest(path: "/recipes/\(id)", method: .DELETE)
|
|
||||||
let _ = try await sendRaw(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCategories() async throws -> [Category] {
|
|
||||||
let request = makeRequest(path: "/categories", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCategory(named categoryName: String) async throws -> [Recipe] {
|
|
||||||
let request = makeRequest(path: "/category/\(categoryName)", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func renameCategory(named categoryName: String, to newName: String) async throws {
|
|
||||||
guard let body = JSONEncoder.safeEncode(["name": newName]) else {
|
|
||||||
throw NetworkError.encodingFailed(detail: "Failed to encode category name")
|
|
||||||
}
|
|
||||||
let request = makeRequest(path: "/category/\(categoryName)", method: .PUT, contentType: .JSON, body: body)
|
|
||||||
let _ = try await sendRaw(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getTags() async throws -> [RecipeKeyword] {
|
|
||||||
let request = makeRequest(path: "/keywords", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRecipesTagged(keyword: String) async throws -> [Recipe] {
|
|
||||||
let request = makeRequest(path: "/tags/\(keyword)", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func searchRecipes(query: String) async throws -> [Recipe] {
|
|
||||||
let request = makeRequest(path: "/search/\(query)", method: .GET)
|
|
||||||
return try await sendAndDecode(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
//
|
|
||||||
// CustomError.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 13.09.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum NetworkError: Error, LocalizedError {
|
|
||||||
case missingUrl
|
|
||||||
case encodingFailed(detail: String? = nil)
|
|
||||||
case decodingFailed(detail: String? = nil)
|
|
||||||
case httpError(statusCode: Int, body: String? = nil)
|
|
||||||
case connectionError(underlying: Error? = nil)
|
|
||||||
case invalidRequest
|
|
||||||
case unknownError(detail: String? = nil)
|
|
||||||
|
|
||||||
public var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .missingUrl:
|
|
||||||
return "Missing URL."
|
|
||||||
case .encodingFailed(let detail):
|
|
||||||
return "Parameter encoding failed." + (detail.map { " \($0)" } ?? "")
|
|
||||||
case .decodingFailed(let detail):
|
|
||||||
return "Data decoding failed." + (detail.map { " \($0)" } ?? "")
|
|
||||||
case .httpError(let statusCode, let body):
|
|
||||||
return "HTTP error \(statusCode)." + (body.map { " \($0)" } ?? "")
|
|
||||||
case .connectionError(let underlying):
|
|
||||||
return "Connection error." + (underlying.map { " \($0.localizedDescription)" } ?? "")
|
|
||||||
case .invalidRequest:
|
|
||||||
return "Invalid request."
|
|
||||||
case .unknownError(let detail):
|
|
||||||
return "Unknown error." + (detail.map { " \($0)" } ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var isClientError: Bool {
|
|
||||||
if case .httpError(let statusCode, _) = self {
|
|
||||||
return (400...499).contains(statusCode)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var isServerError: Bool {
|
|
||||||
if case .httpError(let statusCode, _) = self {
|
|
||||||
return (500...599).contains(statusCode)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) }
|
print("Full path: \(urlString)")
|
||||||
// Ensure path percent encoding is applied correctly
|
//Logger.network.debug("Full path: \(urlString)")
|
||||||
components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path
|
guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) }
|
||||||
guard let url = components.url else { return (nil, .missingUrl) }
|
guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) }
|
||||||
|
|
||||||
// Create URL request
|
// Create URL request
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
@@ -65,31 +65,38 @@ 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 {
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
(data, response) = try await URLSession.shared.data(for: request)
|
||||||
if let error = decodeURLResponse(response: response as? HTTPURLResponse, data: data) {
|
Logger.network.debug("\(method.rawValue) \(path) received response ...")
|
||||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
|
||||||
|
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
||||||
return (nil, error)
|
return (nil, error)
|
||||||
}
|
}
|
||||||
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
if let data = data {
|
||||||
|
print(data, String(data: data, encoding: .utf8) as Any)
|
||||||
return (data, nil)
|
return (data, nil)
|
||||||
|
}
|
||||||
|
return (nil, .unknownError)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
let error = decodeURLResponse(response: response as? HTTPURLResponse)
|
||||||
return (nil, .connectionError(underlying: error))
|
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)")
|
||||||
|
return (nil, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? {
|
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
||||||
guard let response = response else {
|
guard let response = response else {
|
||||||
return .unknownError(detail: "No HTTP response")
|
return NetworkError.unknownError
|
||||||
}
|
}
|
||||||
let statusCode = response.statusCode
|
print("Status code: ", response.statusCode)
|
||||||
switch statusCode {
|
switch response.statusCode {
|
||||||
case 200...299:
|
case 200...299: return (nil)
|
||||||
return nil
|
case 300...399: return (NetworkError.redirectionError)
|
||||||
default:
|
case 400...499: return (NetworkError.clientError(statusCode: response.statusCode))
|
||||||
let body = data.flatMap { String(data: $0, encoding: .utf8) }
|
case 500...599: return (NetworkError.serverError(statusCode: response.statusCode))
|
||||||
return .httpError(statusCode: statusCode, body: body)
|
default: return (NetworkError.unknownError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// CookbookApi.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 13.11.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
/// The Cookbook API class used for requests to the Nextcloud Cookbook service.
|
||||||
|
let cookbookApi: CookbookApi.Type = {
|
||||||
|
switch UserSettings.shared.cookbookApiVersion {
|
||||||
|
case .v1:
|
||||||
|
return CookbookApiV1.self
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// The Cookbook API version.
|
||||||
|
enum CookbookApiVersion: String {
|
||||||
|
case v1 = "v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// A protocol defining common API endpoints that are likely to remain the same over future Cookbook API versions.
|
||||||
|
protocol CookbookApi {
|
||||||
|
static var basePath: String { get }
|
||||||
|
|
||||||
|
/// Not implemented yet.
|
||||||
|
static func importRecipe(
|
||||||
|
auth: String,
|
||||||
|
data: Data
|
||||||
|
) async -> (Recipe?, NetworkError?)
|
||||||
|
|
||||||
|
/// Get either the full image or a thumbnail sized version.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - id: The according recipe id.
|
||||||
|
/// - size: The size of the image.
|
||||||
|
/// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil.
|
||||||
|
static func getImage(
|
||||||
|
auth: String,
|
||||||
|
id: String,
|
||||||
|
size: RecipeImage.RecipeImageSize
|
||||||
|
) async -> (UIImage?, NetworkError?)
|
||||||
|
|
||||||
|
/// Get all recipes.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - Returns: A list of all recipes.
|
||||||
|
static func getRecipes(
|
||||||
|
auth: String
|
||||||
|
) async -> ([RecipeStub]?, NetworkError?)
|
||||||
|
|
||||||
|
/// Create a new recipe.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - Returns: A NetworkError if the request fails. Nil otherwise.
|
||||||
|
static func createRecipe(
|
||||||
|
auth: String,
|
||||||
|
recipe: Recipe
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Get the recipe with the specified id.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - id: The recipe id.
|
||||||
|
/// - Returns: The recipe if it exists. A NetworkError if the request fails.
|
||||||
|
static func getRecipe(
|
||||||
|
auth: String,
|
||||||
|
id: String
|
||||||
|
) async -> (Recipe?, NetworkError?)
|
||||||
|
|
||||||
|
/// Update an existing recipe with new entries.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - id: The recipe id.
|
||||||
|
/// - Returns: A NetworkError if the request fails. Nil otherwise.
|
||||||
|
static func updateRecipe(
|
||||||
|
auth: String,
|
||||||
|
recipe: Recipe
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Delete the recipe with the specified id.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - id: The recipe id.
|
||||||
|
/// - Returns: A NetworkError if the request fails. Nil otherwise.
|
||||||
|
static func deleteRecipe(
|
||||||
|
auth: String,
|
||||||
|
id: String
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Get all categories.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - Returns: A list of categories. A NetworkError if the request fails.
|
||||||
|
static func getCategories(
|
||||||
|
auth: String
|
||||||
|
) async -> ([CookbookApiCategory]?, NetworkError?)
|
||||||
|
|
||||||
|
/// Get all recipes of a specified category.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - categoryName: The category name.
|
||||||
|
/// - Returns: A list of recipes. A NetworkError if the request fails.
|
||||||
|
static func getCategory(
|
||||||
|
auth: String,
|
||||||
|
named categoryName: String
|
||||||
|
) async -> ([RecipeStub]?, NetworkError?)
|
||||||
|
|
||||||
|
/// Rename an existing category.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - categoryName: The name of the category to be renamed.
|
||||||
|
/// - newName: The new category name.
|
||||||
|
/// - Returns: A NetworkError if the request fails.
|
||||||
|
static func renameCategory(
|
||||||
|
auth: String,
|
||||||
|
named categoryName: String,
|
||||||
|
newName: String
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Get all keywords/tags.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - Returns: A list of tag strings. A NetworkError if the request fails.
|
||||||
|
static func getTags(
|
||||||
|
auth: String
|
||||||
|
) async -> ([RecipeKeyword]?, NetworkError?)
|
||||||
|
|
||||||
|
/// Get all recipes tagged with the specified keyword.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - keyword: The keyword.
|
||||||
|
/// - Returns: A list of recipes tagged with the specified keyword. A NetworkError if the request fails.
|
||||||
|
static func getRecipesTagged(
|
||||||
|
auth: String,
|
||||||
|
keyword: String
|
||||||
|
) async -> ([RecipeStub]?, NetworkError?)
|
||||||
|
|
||||||
|
/// Get the servers api version.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string.
|
||||||
|
/// - Returns: A NetworkError if the request fails.
|
||||||
|
static func getApiVersion(
|
||||||
|
auth: String
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Trigger a reindexing action on the server.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string
|
||||||
|
/// - Returns: A NetworkError if the request fails.
|
||||||
|
static func postReindex(
|
||||||
|
auth: String
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Get the current configuration of the Cookbook server application.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string
|
||||||
|
/// - Returns: A NetworkError if the request fails.
|
||||||
|
static func getConfig(
|
||||||
|
auth: String
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
|
||||||
|
/// Set the current configuration of the Cookbook server application.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - auth: Server authentication string
|
||||||
|
/// - Returns: A NetworkError if the request fails.
|
||||||
|
static func postConfig(
|
||||||
|
auth: String
|
||||||
|
) async -> (NetworkError?)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//
|
||||||
|
// CookbookApiV1.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 16.11.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
|
class CookbookApiV1: CookbookApi {
|
||||||
|
static let basePath: String = "/index.php/apps/cookbook/api/v1"
|
||||||
|
|
||||||
|
static func importRecipe(auth: String, data: Data) async -> (Recipe?, NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/import",
|
||||||
|
method: .POST,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
|
||||||
|
return (recipe?.toRecipe(), error)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getImage(auth: String, id: String, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
|
||||||
|
guard let id = Int(id) else {return (nil, .unknownError)}
|
||||||
|
let imageSize = (size == .FULL ? "full" : "thumb")
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/recipes/\(id)/image?size=\(imageSize)",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .IMAGE)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
return (UIImage(data: data), error)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getRecipes(auth: String) async -> ([RecipeStub]?, NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/recipes",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
print("\n\nRECIPE: ", String(data: data, encoding: .utf8))
|
||||||
|
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
|
||||||
|
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
|
||||||
|
let recipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
|
||||||
|
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
||||||
|
return .dataError
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/recipes",
|
||||||
|
method: .POST,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)],
|
||||||
|
body: recipeData
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (error) }
|
||||||
|
do {
|
||||||
|
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||||
|
if let id = json as? Int {
|
||||||
|
return nil
|
||||||
|
} else if let dict = json as? [String: Any] {
|
||||||
|
return .unknownError
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return .decodingFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getRecipe(auth: String, id: String) async -> (Recipe?, NetworkError?) {
|
||||||
|
guard let id = Int(id) else {return (nil, .unknownError)}
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/recipes/\(id)",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
|
||||||
|
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
|
||||||
|
return (recipe?.toRecipe(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
|
||||||
|
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
|
||||||
|
guard let recipeData = JSONEncoder.safeEncode(cookbookRecipe) else {
|
||||||
|
return .dataError
|
||||||
|
}
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/recipes/\(recipe.id)",
|
||||||
|
method: .PUT,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), HeaderField.contentType(value: .JSON)],
|
||||||
|
body: recipeData
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (error) }
|
||||||
|
do {
|
||||||
|
let json = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
|
||||||
|
if let id = json as? Int {
|
||||||
|
return nil
|
||||||
|
} else if let dict = json as? [String: Any] {
|
||||||
|
return .unknownError
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return .decodingFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deleteRecipe(auth: String, id: String) async -> (NetworkError?) {
|
||||||
|
guard let id = Int(id) else {return .unknownError}
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/recipes/\(id)",
|
||||||
|
method: .DELETE,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (error) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getCategories(auth: String) async -> ([CookbookApiCategory]?, NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/categories",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
return (JSONDecoder.safeDecode(data), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getCategory(auth: String, named categoryName: String) async -> ([RecipeStub]?, NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/category/\(categoryName)",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
|
||||||
|
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/category/\(categoryName)",
|
||||||
|
method: .PUT,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (error) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getTags(auth: String) async -> ([RecipeKeyword]?, NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/keywords",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
return (JSONDecoder.safeDecode(data), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getRecipesTagged(auth: String, keyword: String) async -> ([RecipeStub]?, NetworkError?) {
|
||||||
|
let request = ApiRequest(
|
||||||
|
path: basePath + "/tags/\(keyword)",
|
||||||
|
method: .GET,
|
||||||
|
authString: auth,
|
||||||
|
headerFields: [HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let (data, error) = await request.send()
|
||||||
|
guard let data = data else { return (nil, error) }
|
||||||
|
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
|
||||||
|
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getApiVersion(auth: String) async -> (NetworkError?) {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
static func postReindex(auth: String) async -> (NetworkError?) {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getConfig(auth: String) async -> (NetworkError?) {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
static func postConfig(auth: String) async -> (NetworkError?) {
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,38 +1,15 @@
|
|||||||
//
|
//
|
||||||
// DataModels.swift
|
// CookbookLoginModels.swift
|
||||||
// Nextcloud Cookbook iOS Client
|
// Nextcloud Cookbook iOS Client
|
||||||
//
|
//
|
||||||
// Created by Vincent Meilinger on 15.09.23.
|
// Created by Vincent Meilinger on 11.05.24.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
struct Category: Codable {
|
// MARK: - Login Models
|
||||||
let name: String
|
|
||||||
let recipe_count: Int
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case name, recipe_count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Category: Identifiable, Hashable {
|
|
||||||
var id: String { name }
|
|
||||||
|
|
||||||
static func == (lhs: Category, rhs: Category) -> Bool {
|
|
||||||
lhs.name == rhs.name
|
|
||||||
}
|
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
|
||||||
hasher.combine(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Login flow
|
|
||||||
|
|
||||||
struct LoginV2Request: Codable {
|
struct LoginV2Request: Codable {
|
||||||
let poll: LoginV2Poll
|
let poll: LoginV2Poll
|
||||||
@@ -62,6 +39,3 @@ struct MetaData: Codable {
|
|||||||
let status: String
|
let status: String
|
||||||
let statuscode: Int
|
let statuscode: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -8,8 +8,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CookbookApiCategory: Codable, Identifiable, Hashable {
|
||||||
|
var id: String { name }
|
||||||
|
var name: String
|
||||||
|
var recipe_count: Int
|
||||||
|
|
||||||
struct Recipe: Codable {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case name, recipe_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CookbookApiRecipeV1: CookbookApiRecipe, Codable, Identifiable, Hashable {
|
||||||
|
var id: String { name + String(recipe_id) }
|
||||||
let name: String
|
let name: String
|
||||||
let keywords: String?
|
let keywords: String?
|
||||||
let dateCreated: String?
|
let dateCreated: String?
|
||||||
@@ -24,15 +34,22 @@ struct Recipe: Codable {
|
|||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
|
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toRecipeStub() -> RecipeStub {
|
||||||
|
return RecipeStub(
|
||||||
|
id: String(recipe_id),
|
||||||
|
name: name,
|
||||||
|
keywords: keywords,
|
||||||
|
dateCreated: dateCreated,
|
||||||
|
dateModified: dateModified,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension Recipe: Identifiable, Hashable {
|
|
||||||
var id: Int { recipe_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
|
||||||
struct RecipeDetail: Codable {
|
|
||||||
var name: String
|
var name: String
|
||||||
var keywords: String
|
var keywords: String
|
||||||
var dateCreated: String?
|
var dateCreated: String?
|
||||||
@@ -50,10 +67,8 @@ struct RecipeDetail: Codable {
|
|||||||
var recipeIngredient: [String]
|
var recipeIngredient: [String]
|
||||||
var recipeInstructions: [String]
|
var recipeInstructions: [String]
|
||||||
var nutrition: [String:String]
|
var nutrition: [String:String]
|
||||||
var groceryState: GroceryState?
|
|
||||||
var mealPlanAssignment: MealPlanAssignment?
|
|
||||||
|
|
||||||
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String], groceryState: GroceryState? = nil, mealPlanAssignment: MealPlanAssignment? = nil) {
|
init(name: String, keywords: String, dateCreated: String?, dateModified: String?, imageUrl: String?, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String?, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.keywords = keywords
|
self.keywords = keywords
|
||||||
self.dateCreated = dateCreated
|
self.dateCreated = dateCreated
|
||||||
@@ -71,8 +86,6 @@ struct RecipeDetail: Codable {
|
|||||||
self.recipeIngredient = recipeIngredient
|
self.recipeIngredient = recipeIngredient
|
||||||
self.recipeInstructions = recipeInstructions
|
self.recipeInstructions = recipeInstructions
|
||||||
self.nutrition = nutrition
|
self.nutrition = nutrition
|
||||||
self.groceryState = groceryState
|
|
||||||
self.mealPlanAssignment = mealPlanAssignment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -93,87 +106,81 @@ struct RecipeDetail: Codable {
|
|||||||
recipeIngredient = []
|
recipeIngredient = []
|
||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
groceryState = nil
|
|
||||||
mealPlanAssignment = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom decoder to handle value type ambiguity
|
// Custom decoder to handle value type ambiguity
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
|
case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
|
||||||
case groceryState = "_groceryState"
|
|
||||||
case mealPlanAssignment = "_mealPlanAssignment"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
name = try container.decode(String.self, forKey: .name)
|
name = try container.decode(String.self, forKey: .name)
|
||||||
keywords = (try? container.decode(String.self, forKey: .keywords)) ?? ""
|
keywords = try container.decode(String.self, forKey: .keywords)
|
||||||
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
|
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
|
||||||
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
||||||
// Server import returns "image"; show/index responses and local storage use "imageUrl"
|
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
|
||||||
imageUrl = (try? container.decodeIfPresent(String.self, forKey: .image))
|
|
||||||
?? (try? container.decodeIfPresent(String.self, forKey: .imageUrl))
|
|
||||||
id = try container.decode(String.self, forKey: .id)
|
id = try container.decode(String.self, forKey: .id)
|
||||||
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
|
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
|
||||||
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
|
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
|
||||||
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
|
||||||
description = (try? container.decode(String.self, forKey: .description)) ?? ""
|
description = try container.decode(String.self, forKey: .description)
|
||||||
url = try? container.decode(String.self, forKey: .url)
|
url = try container.decode(String.self, forKey: .url)
|
||||||
recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? ""
|
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
|
||||||
|
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
|
||||||
|
tool = try container.decode([String].self, forKey: .tool)
|
||||||
|
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
|
||||||
|
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
|
||||||
|
|
||||||
// recipeYield: try Int first, then parse leading digits from String
|
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
|
||||||
if let yieldInt = try? container.decode(Int.self, forKey: .recipeYield) {
|
|
||||||
recipeYield = yieldInt
|
|
||||||
} else if let yieldString = try? container.decode(String.self, forKey: .recipeYield) {
|
|
||||||
let digits = yieldString.prefix(while: { $0.isNumber })
|
|
||||||
recipeYield = Int(digits) ?? 0
|
|
||||||
} else {
|
|
||||||
recipeYield = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tool = (try? container.decode([String].self, forKey: .tool)) ?? []
|
func toRecipe() -> Recipe {
|
||||||
recipeIngredient = (try? container.decode([String].self, forKey: .recipeIngredient)) ?? []
|
return Recipe(
|
||||||
recipeInstructions = (try? container.decode([String].self, forKey: .recipeInstructions)) ?? []
|
id: self.id,
|
||||||
|
name: self.name,
|
||||||
if let nutritionDict = try? container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition) {
|
keywords: keywords.components(separatedBy: ","),
|
||||||
nutrition = nutritionDict.mapValues { String(describing: $0.value) }
|
dateCreated: self.dateCreated,
|
||||||
} else {
|
dateModified: self.dateModified,
|
||||||
nutrition = [:]
|
prepTime: self.prepTime ?? "",
|
||||||
|
cookTime: self.cookTime ?? "",
|
||||||
|
totalTime: self.totalTime ?? "",
|
||||||
|
recipeDescription: self.description,
|
||||||
|
url: self.url,
|
||||||
|
yield: self.recipeYield,
|
||||||
|
category: self.recipeCategory,
|
||||||
|
tools: self.tool,
|
||||||
|
ingredients: self.recipeIngredient,
|
||||||
|
instructions: self.recipeInstructions,
|
||||||
|
nutrition: self.nutrition,
|
||||||
|
ingredientMultiplier: 1.0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
|
static func fromRecipe(_ recipe: Recipe) -> any CookbookApiRecipeDetail {
|
||||||
mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment)
|
return CookbookApiRecipeDetailV1(
|
||||||
}
|
name: recipe.name,
|
||||||
|
keywords: recipe.keywords.joined(separator: ","),
|
||||||
func encode(to encoder: Encoder) throws {
|
dateCreated: recipe.dateCreated,
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
dateModified: recipe.dateModified,
|
||||||
try container.encode(name, forKey: .name)
|
imageUrl: "",
|
||||||
try container.encode(keywords, forKey: .keywords)
|
id: recipe.id,
|
||||||
try container.encodeIfPresent(dateCreated, forKey: .dateCreated)
|
description: recipe.recipeDescription,
|
||||||
try container.encodeIfPresent(dateModified, forKey: .dateModified)
|
url: recipe.url,
|
||||||
// Encode under "image" — the key the server expects for create/update
|
recipeYield: recipe.yield,
|
||||||
try container.encodeIfPresent(imageUrl, forKey: .image)
|
recipeCategory: recipe.category,
|
||||||
try container.encode(id, forKey: .id)
|
tool: recipe.tools,
|
||||||
try container.encodeIfPresent(prepTime, forKey: .prepTime)
|
recipeIngredient: recipe.ingredients,
|
||||||
try container.encodeIfPresent(cookTime, forKey: .cookTime)
|
recipeInstructions: recipe.instructions,
|
||||||
try container.encodeIfPresent(totalTime, forKey: .totalTime)
|
nutrition: recipe.nutrition
|
||||||
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)
|
|
||||||
try container.encodeIfPresent(groceryState, forKey: .groceryState)
|
|
||||||
try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension RecipeDetail {
|
extension CookbookApiRecipeDetailV1 {
|
||||||
static var error: RecipeDetail {
|
static var error: CookbookApiRecipeDetailV1 {
|
||||||
return RecipeDetail(
|
return CookbookApiRecipeDetailV1(
|
||||||
name: "Error: Unable to load recipe.",
|
name: "Error: Unable to load recipe.",
|
||||||
keywords: "",
|
keywords: "",
|
||||||
dateCreated: "",
|
dateCreated: "",
|
||||||
@@ -206,7 +213,7 @@ extension RecipeDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
struct RecipeImage {
|
struct RecipeImage {
|
||||||
enum RecipeImageSize: String {
|
enum RecipeImageSize: String {
|
||||||
case THUMB="thumb", FULL="full"
|
case THUMB="thumb", FULL="full"
|
||||||
@@ -215,7 +222,7 @@ struct RecipeImage {
|
|||||||
var thumb: UIImage?
|
var thumb: UIImage?
|
||||||
var full: UIImage?
|
var full: UIImage?
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
struct RecipeKeyword: Codable {
|
struct RecipeKeyword: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
@@ -295,3 +302,4 @@ enum Nutrition: CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// CookbookProtocols.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 11.05.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
|
protocol CookbookApiRecipe {
|
||||||
|
func toRecipeStub() -> RecipeStub
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol CookbookApiRecipeDetail: Codable {
|
||||||
|
func toRecipe() -> Recipe
|
||||||
|
static func fromRecipe(_ recipe: Recipe) -> CookbookApiRecipeDetail
|
||||||
|
}
|
||||||
58
Nextcloud Cookbook iOS Client/Networking/NetworkError.swift
Normal file
58
Nextcloud Cookbook iOS Client/Networking/NetworkError.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// CustomError.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 13.09.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
public enum NetworkError: UserAlert {
|
||||||
|
case missingUrl
|
||||||
|
case parametersNil
|
||||||
|
case encodingFailed
|
||||||
|
case decodingFailed
|
||||||
|
case redirectionError
|
||||||
|
case clientError(statusCode: Int)
|
||||||
|
case serverError(statusCode: Int)
|
||||||
|
case invalidRequest
|
||||||
|
case unknownError
|
||||||
|
case dataError
|
||||||
|
|
||||||
|
var localizedTitle: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .missingUrl:
|
||||||
|
"Missing URL."
|
||||||
|
case .parametersNil:
|
||||||
|
"Parameters are nil."
|
||||||
|
case .encodingFailed:
|
||||||
|
"Parameter encoding failed."
|
||||||
|
case .decodingFailed:
|
||||||
|
"Data decoding failed."
|
||||||
|
case .redirectionError:
|
||||||
|
"Redirection error"
|
||||||
|
case .clientError(let code):
|
||||||
|
"Client error: \(code)"
|
||||||
|
case .serverError(let code):
|
||||||
|
"Server error: \(code)"
|
||||||
|
case .invalidRequest:
|
||||||
|
"Invalid request"
|
||||||
|
case .unknownError:
|
||||||
|
"Unknown error"
|
||||||
|
case .dataError:
|
||||||
|
"Invalid data error."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localizedDescription: LocalizedStringKey {
|
||||||
|
return self.localizedTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
var alertButtons: [AlertButton] {
|
||||||
|
return [.OK]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -44,3 +44,7 @@ struct HeaderField {
|
|||||||
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RecipeImportRequest: Codable {
|
||||||
|
let url: String
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
|
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
|
||||||
@@ -21,10 +20,9 @@ class NextcloudApi {
|
|||||||
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
|
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
|
||||||
/// - `NetworkError?`: An error encountered during the network request, if any.
|
/// - `NetworkError?`: An error encountered during the network request, if any.
|
||||||
|
|
||||||
static func loginV2Request() async -> (LoginV2Request?, NetworkError?) {
|
static func loginV2Request(_ baseAddress: String) async -> (LoginV2Request?, NetworkError?) {
|
||||||
let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress
|
|
||||||
let request = ApiRequest(
|
let request = ApiRequest(
|
||||||
path: path + "/index.php/login/v2",
|
path: baseAddress + "/index.php/login/v2",
|
||||||
method: .POST
|
method: .POST
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,10 +32,10 @@ class NextcloudApi {
|
|||||||
return (nil, error)
|
return (nil, error)
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
return (nil, NetworkError.encodingFailed())
|
return (nil, NetworkError.dataError)
|
||||||
}
|
}
|
||||||
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
|
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
|
||||||
return (nil, NetworkError.decodingFailed())
|
return (nil, NetworkError.decodingFailed)
|
||||||
}
|
}
|
||||||
return (loginRequest, nil)
|
return (loginRequest, nil)
|
||||||
}
|
}
|
||||||
@@ -53,16 +51,16 @@ class NextcloudApi {
|
|||||||
/// - `LoginV2Response?`: An object representing the response of the login process, if successful.
|
/// - `LoginV2Response?`: An object representing the response of the login process, if successful.
|
||||||
/// - `NetworkError?`: An error encountered during the network request, if any.
|
/// - `NetworkError?`: An error encountered during the network request, if any.
|
||||||
|
|
||||||
static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) {
|
static func loginV2Poll(pollURL: String, pollToken: String) async -> (LoginV2Response?, NetworkError?) {
|
||||||
let request = ApiRequest(
|
let request = ApiRequest(
|
||||||
path: req.poll.endpoint,
|
path: pollURL,
|
||||||
method: .POST,
|
method: .POST,
|
||||||
headerFields: [
|
headerFields: [
|
||||||
HeaderField.ocsRequest(value: true),
|
HeaderField.ocsRequest(value: true),
|
||||||
HeaderField.accept(value: .JSON),
|
HeaderField.accept(value: .JSON),
|
||||||
HeaderField.contentType(value: .FORM)
|
HeaderField.contentType(value: .FORM)
|
||||||
],
|
],
|
||||||
body: "token=\(req.poll.token)".data(using: .utf8)
|
body: "token=\(pollToken)".data(using: .utf8)
|
||||||
)
|
)
|
||||||
let (data, error) = await request.send(pathCompletion: false)
|
let (data, error) = await request.send(pathCompletion: false)
|
||||||
|
|
||||||
@@ -70,10 +68,10 @@ class NextcloudApi {
|
|||||||
return (nil, error)
|
return (nil, error)
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
return (nil, NetworkError.encodingFailed())
|
return (nil, NetworkError.dataError)
|
||||||
}
|
}
|
||||||
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
|
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
|
||||||
return (nil, NetworkError.decodingFailed())
|
return (nil, NetworkError.decodingFailed)
|
||||||
}
|
}
|
||||||
return (loginResponse, nil)
|
return (loginResponse, nil)
|
||||||
}
|
}
|
||||||
@@ -108,11 +106,11 @@ class NextcloudApi {
|
|||||||
userId: data?["userId"] as? String ?? "",
|
userId: data?["userId"] as? String ?? "",
|
||||||
userDisplayName: data?["displayName"] as? String ?? ""
|
userDisplayName: data?["displayName"] as? String ?? ""
|
||||||
)
|
)
|
||||||
Logger.network.debug("Loaded hover card for user \(userData.userId)")
|
print(userData)
|
||||||
return (userData, nil)
|
return (userData, nil)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.network.error("Failed to decode hover card: \(error.localizedDescription)")
|
print(error.localizedDescription)
|
||||||
return (nil, NetworkError.decodingFailed())
|
return (nil, NetworkError.decodingFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,51 +6,34 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
||||||
@AppStorage("onboarding") var onboarding = true
|
@AppStorage("onboarding") var onboarding = true
|
||||||
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||||
@AppStorage("appearanceMode") var appearanceMode = AppearanceMode.system.rawValue
|
|
||||||
|
|
||||||
@State private var pendingImportURL: String?
|
|
||||||
|
|
||||||
var colorScheme: ColorScheme? {
|
|
||||||
switch appearanceMode {
|
|
||||||
case AppearanceMode.light.rawValue: return .light
|
|
||||||
case AppearanceMode.dark.rawValue: return .dark
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ZStack {
|
ZStack {
|
||||||
if onboarding {
|
if onboarding {
|
||||||
OnboardingView()
|
//OnboardingView()
|
||||||
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
MainView(pendingImportURL: $pendingImportURL)
|
MainView()
|
||||||
|
.modelContainer(for: [Recipe.self, GroceryItem.self, RecipeGroceries.self])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.preferredColorScheme(colorScheme)
|
|
||||||
.transition(.slide)
|
.transition(.slide)
|
||||||
.environment(
|
.environment(
|
||||||
\.locale,
|
\.locale,
|
||||||
.init(identifier: language ==
|
.init(identifier: language ==
|
||||||
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
|
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
|
||||||
)
|
)
|
||||||
.onOpenURL { url in
|
.onAppear {
|
||||||
guard !onboarding else { return }
|
AuthManager.shared.loadAuthString() // Load the auth string as soon as possible
|
||||||
guard url.scheme == "nextcloud-cookbook",
|
|
||||||
url.host == "import",
|
|
||||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
||||||
let recipeURL = components.queryItems?.first(where: { $0.name == "url" })?.value,
|
|
||||||
!recipeURL.isEmpty
|
|
||||||
else { return }
|
|
||||||
pendingImportURL = recipeURL
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
222
Nextcloud Cookbook iOS Client/Persistence/CookbookState.swift
Normal file
222
Nextcloud Cookbook iOS Client/Persistence/CookbookState.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
//
|
||||||
|
// CookbookState.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 29.05.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
/*
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class CookbookState {
|
||||||
|
/// Caches recipe categories.
|
||||||
|
var categories: [Category] = []
|
||||||
|
|
||||||
|
/// Caches RecipeStubs.
|
||||||
|
var recipeStubs: [String: [RecipeStub]] = [:]
|
||||||
|
|
||||||
|
/// Caches Recipes by recipe id.
|
||||||
|
var recipes: [String: Recipe] = [:]
|
||||||
|
|
||||||
|
/// Caches recipe thumbnails by recipe id.
|
||||||
|
var thumbnails: [String: UIImage] = [:]
|
||||||
|
|
||||||
|
/// Caches recipe images by recipe id.
|
||||||
|
var images: [String: UIImage] = [:]
|
||||||
|
|
||||||
|
/// Caches recipe keywords.
|
||||||
|
var keywords: [RecipeKeyword] = []
|
||||||
|
|
||||||
|
/// Read and write interfaces.
|
||||||
|
var localReadInterface: ReadInterface
|
||||||
|
var localWriteInterface: WriteInterface
|
||||||
|
var remoteReadInterface: ReadInterface?
|
||||||
|
var remoteWriteInterface: WriteInterface?
|
||||||
|
|
||||||
|
/// UI state variables
|
||||||
|
var selectedCategory: Category? = nil
|
||||||
|
var selectedRecipeStub: RecipeStub? = nil
|
||||||
|
var showSettings: Bool = false
|
||||||
|
var showGroceries: Bool = false
|
||||||
|
|
||||||
|
/// Grocery List
|
||||||
|
var groceryList = GroceryList()
|
||||||
|
|
||||||
|
init(
|
||||||
|
localReadInterface: ReadInterface,
|
||||||
|
localWriteInterface: WriteInterface,
|
||||||
|
remoteReadInterface: ReadInterface? = nil,
|
||||||
|
remoteWriteInterface: WriteInterface? = nil
|
||||||
|
|
||||||
|
) {
|
||||||
|
self.localReadInterface = localReadInterface
|
||||||
|
self.localWriteInterface = localWriteInterface
|
||||||
|
self.remoteReadInterface = remoteReadInterface
|
||||||
|
self.remoteWriteInterface = remoteWriteInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let accountLoader = AccountLoader()
|
||||||
|
rI, wI = accountLoader.load
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension CookbookState {
|
||||||
|
func loadCategories(remoteFirst: Bool = false) async {
|
||||||
|
if remoteFirst {
|
||||||
|
if let remoteReadInterface, let categories = await remoteReadInterface.getCategories() {
|
||||||
|
self.categories = categories
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let categories = await localReadInterface.getCategories() {
|
||||||
|
self.categories = categories
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let categories = await localReadInterface.getCategories() {
|
||||||
|
self.categories = categories
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let remoteReadInterface else { return }
|
||||||
|
if let categories = await remoteReadInterface.getCategories() {
|
||||||
|
self.categories = categories
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecipeStubs(category: String, remoteFirst: Bool = false) async {
|
||||||
|
if remoteFirst {
|
||||||
|
if let remoteReadInterface, let stubs = await remoteReadInterface.getRecipeStubs() {
|
||||||
|
self.recipeStubs[category] = stubs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let stubs = await localReadInterface.getRecipeStubs() {
|
||||||
|
self.recipeStubs[category] = stubs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let stubs = await localReadInterface.getRecipeStubs() {
|
||||||
|
self.recipeStubs[category] = stubs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let remoteReadInterface else { return }
|
||||||
|
if let stubs = await remoteReadInterface.getRecipeStubs() {
|
||||||
|
self.recipeStubs[category] = stubs
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadKeywords(remoteFirst: Bool = false) async {
|
||||||
|
if remoteFirst {
|
||||||
|
if let remoteReadInterface, let keywords = await remoteReadInterface.getTags() {
|
||||||
|
self.keywords = keywords
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let keywords = await localReadInterface.getTags() {
|
||||||
|
self.keywords = keywords
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let keywords = await localReadInterface.getTags() {
|
||||||
|
self.keywords = keywords
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let remoteReadInterface else { return }
|
||||||
|
if let keywords = await remoteReadInterface.getTags() {
|
||||||
|
self.keywords = keywords
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRecipe(id: String, remoteFirst: Bool = false) async {
|
||||||
|
if remoteFirst {
|
||||||
|
if let remoteReadInterface, let recipe = await remoteReadInterface.getRecipe(id: id) {
|
||||||
|
self.recipes[id] = recipe
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let recipe = await localReadInterface.getRecipe(id: id) {
|
||||||
|
self.recipes[id] = recipe
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let recipe = await localReadInterface.getRecipe(id: id) {
|
||||||
|
self.recipes[id] = recipe
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let remoteReadInterface else { return }
|
||||||
|
if let recipe = await remoteReadInterface.getRecipe(id: id) {
|
||||||
|
self.recipes[id] = recipe
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class AccountLoader {
|
||||||
|
func initInterfaces() async -> [ReadInterface & WriteInterface] {
|
||||||
|
let accounts = await self.loadAccounts("accounts.data")
|
||||||
|
|
||||||
|
if accounts.isEmpty && UserSettings.shared.serverAddress != "" {
|
||||||
|
print("Creating new Account from legacy Cookbook client account.")
|
||||||
|
let auth = Authentication(
|
||||||
|
baseUrl: UserSettings.shared.serverAddress,
|
||||||
|
user: UserSettings.shared.username,
|
||||||
|
token: UserSettings.shared.authString
|
||||||
|
)
|
||||||
|
let authKey = "legacyNextcloud"
|
||||||
|
let legacyAccount = Account(
|
||||||
|
id: UUID(),
|
||||||
|
name: "Nextcloud",
|
||||||
|
type: .nextcloud,
|
||||||
|
apiVersion: "1.0",
|
||||||
|
authKey: authKey
|
||||||
|
)
|
||||||
|
await saveAccounts([legacyAccount], "accounts.data")
|
||||||
|
legacyAccount.storeAuth(auth)
|
||||||
|
|
||||||
|
let interface = NextcloudDataInterface(auth: auth, version: legacyAccount.apiVersion)
|
||||||
|
return [interface]
|
||||||
|
} else {
|
||||||
|
print("Recovering existing accounts.")
|
||||||
|
var interfaces: [ReadInterface & WriteInterface] = []
|
||||||
|
for account in accounts {
|
||||||
|
if let interface: CookbookInterface = account.getInterface() {
|
||||||
|
interfaces.append(interface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return interfaces
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAccounts(_ path: String) async -> [Account] {
|
||||||
|
do {
|
||||||
|
return try await DataStore.shared.load(fromPath: path) ?? []
|
||||||
|
} catch (let error) {
|
||||||
|
print(error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveAccounts(_ accounts: [Account], _ path: String) async {
|
||||||
|
await DataStore.shared.save(data: accounts, toPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
//
|
||||||
|
// PersistenceInterface.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 06.05.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import KeychainSwift
|
||||||
|
|
||||||
|
/*
|
||||||
|
protocol CookbookInterface {
|
||||||
|
/// A unique id of the interface. Used to associate recipes to their respective accounts.
|
||||||
|
var id: String { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol ReadInterface {
|
||||||
|
/// Get either the full image or a thumbnail sized version.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The according recipe id.
|
||||||
|
/// - size: The size of the image.
|
||||||
|
/// - Returns: The image of the recipe with the specified id. A UserAlert if the request fails, otherwise nil.
|
||||||
|
func getImage(
|
||||||
|
id: String,
|
||||||
|
size: RecipeImage.RecipeImageSize
|
||||||
|
) async -> UIImage?
|
||||||
|
|
||||||
|
/// Get all recipe stubs.
|
||||||
|
/// - Returns: A list of all recipes.
|
||||||
|
func getRecipeStubs(
|
||||||
|
) async -> [RecipeStub]?
|
||||||
|
|
||||||
|
/// Get the recipe with the specified id.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The recipe id.
|
||||||
|
/// - Returns: The recipe if it exists. A UserAlert if the request fails.
|
||||||
|
func getRecipe(
|
||||||
|
id: String
|
||||||
|
) async -> Recipe?
|
||||||
|
|
||||||
|
/// Get all categories.
|
||||||
|
/// - Returns: A list of categories. A UserAlert if the request fails.
|
||||||
|
func getCategories(
|
||||||
|
) async -> [Category]?
|
||||||
|
|
||||||
|
/// Get all recipes of a specified category.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - categoryName: The category name.
|
||||||
|
/// - Returns: A list of recipes. A UserAlert if the request fails.
|
||||||
|
func getRecipeStubsForCategory(
|
||||||
|
named categoryName: String
|
||||||
|
) async -> [RecipeStub]?
|
||||||
|
|
||||||
|
/// Get all keywords/tags.
|
||||||
|
/// - Returns: A list of tag strings. A UserAlert if the request fails.
|
||||||
|
func getTags(
|
||||||
|
) async -> [RecipeKeyword]?
|
||||||
|
|
||||||
|
/// Get all recipes tagged with the specified keyword.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - keyword: The keyword.
|
||||||
|
/// - Returns: A list of recipes tagged with the specified keyword. A UserAlert if the request fails.
|
||||||
|
func getRecipesTagged(
|
||||||
|
keyword: String
|
||||||
|
) async -> [RecipeStub]?
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol WriteInterface {
|
||||||
|
/// Post either the full image or a thumbnail sized version.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The according recipe id.
|
||||||
|
/// - size: The size of the image.
|
||||||
|
/// - Returns: A UserAlert if the request fails, otherwise nil.
|
||||||
|
func postImage(
|
||||||
|
id: String,
|
||||||
|
image: UIImage,
|
||||||
|
size: RecipeImage.RecipeImageSize
|
||||||
|
) async -> (UserAlert?)
|
||||||
|
|
||||||
|
/// Create a new recipe.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - Returns: A UserAlert if the request fails. Nil otherwise.
|
||||||
|
func postRecipe(
|
||||||
|
recipe: Recipe
|
||||||
|
) async -> (UserAlert?)
|
||||||
|
|
||||||
|
/// Update an existing recipe with new entries.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - recipe: The recipe.
|
||||||
|
/// - Returns: A UserAlert if the request fails. Nil otherwise.
|
||||||
|
func updateRecipe(
|
||||||
|
recipe: Recipe
|
||||||
|
) async -> (UserAlert?)
|
||||||
|
|
||||||
|
/// Delete the recipe with the specified id.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - id: The recipe id.
|
||||||
|
/// - Returns: A UserAlert if the request fails. Nil otherwise.
|
||||||
|
func deleteRecipe(
|
||||||
|
id: String
|
||||||
|
) async -> (UserAlert?)
|
||||||
|
|
||||||
|
/// Rename an existing category.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - categoryName: The name of the category to be renamed.
|
||||||
|
/// - newName: The new category name.
|
||||||
|
/// - Returns: A UserAlert if the request fails.
|
||||||
|
func renameCategory(
|
||||||
|
named categoryName: String,
|
||||||
|
newName: String
|
||||||
|
) async -> (UserAlert?)
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//
|
||||||
|
// LocalDataInterface.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 07.05.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
/*
|
||||||
|
|
||||||
|
class LocalDataInterface: CookbookInterface {
|
||||||
|
var id: String
|
||||||
|
|
||||||
|
init(id: String) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LocalDataPath {
|
||||||
|
case recipeStubs(category: String),
|
||||||
|
recipe(id: String),
|
||||||
|
image(id: String, size: RecipeImage.RecipeImageSize),
|
||||||
|
categories,
|
||||||
|
keywords
|
||||||
|
|
||||||
|
var path: String {
|
||||||
|
switch self {
|
||||||
|
case .recipe(let id):
|
||||||
|
"recipe_\(id).data"
|
||||||
|
case .recipeStubs(let category):
|
||||||
|
"recipes_\(category).data"
|
||||||
|
case .image(let id, let size):
|
||||||
|
if size == .FULL {
|
||||||
|
"image_\(id).data"
|
||||||
|
} else {
|
||||||
|
"thumb_\(id).data"
|
||||||
|
}
|
||||||
|
case .categories:
|
||||||
|
"categories.data"
|
||||||
|
case .keywords:
|
||||||
|
"keywords.data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Local Read Interface
|
||||||
|
|
||||||
|
extension LocalDataInterface: ReadInterface {
|
||||||
|
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
|
||||||
|
guard let data: String = await load(path: .image(id: id, size: size)) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let dataDecoded = Data(base64Encoded: data) else { return nil }
|
||||||
|
return UIImage(data: dataDecoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipeStubs() async -> [RecipeStub]? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipe(id: String) async -> Recipe? {
|
||||||
|
return await load(path: LocalDataPath.recipe(id: id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategories() async -> [Category]? {
|
||||||
|
return await load(path: LocalDataPath.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
|
||||||
|
return await load(path: .recipeStubs(category: categoryName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTags() async -> [RecipeKeyword]? {
|
||||||
|
return await load(path: .keywords)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Local Write Interface
|
||||||
|
|
||||||
|
extension LocalDataInterface: WriteInterface {
|
||||||
|
|
||||||
|
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
|
||||||
|
if let data = image.pngData() {
|
||||||
|
await save(
|
||||||
|
data,
|
||||||
|
path: LocalDataPath.image(id: id, size: size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
|
||||||
|
await save(recipe, path: LocalDataPath.recipe(id: recipe.id))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
|
||||||
|
return await postRecipe(recipe: recipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRecipe(id: String) async -> ((any UserAlert)?) {
|
||||||
|
await delete(path: .recipe(id: id))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameCategory(named categoryName: String, newName: String) async -> ((any UserAlert)?) {
|
||||||
|
guard let stubs: [RecipeStub] = await load(path: .recipeStubs(category: categoryName)) else {
|
||||||
|
return PersistenceAlert.LOAD_FAILED
|
||||||
|
}
|
||||||
|
await save(stubs, path: .recipeStubs(category: newName))
|
||||||
|
await delete(path: .recipeStubs(category: categoryName))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Local Data Interface Utils
|
||||||
|
|
||||||
|
extension LocalDataInterface {
|
||||||
|
|
||||||
|
func load<T: Codable>(path ldPath: LocalDataPath) async -> T? {
|
||||||
|
do {
|
||||||
|
return try await DataStore.shared.load(fromPath: ldPath.path)
|
||||||
|
} catch (let error) {
|
||||||
|
print(error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save<T: Codable>(_ object: T, path ldPath: LocalDataPath) async {
|
||||||
|
await DataStore.shared.save(data: object, toPath: ldPath.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(path ldPath: LocalDataPath) async {
|
||||||
|
DataStore.shared.delete(path: ldPath.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//
|
||||||
|
// NextcloudDataInterface.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 07.05.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/*
|
||||||
|
class NextcloudDataInterface: CookbookInterface {
|
||||||
|
var id: String
|
||||||
|
|
||||||
|
var auth: Authentication
|
||||||
|
var api: CookbookApi.Type
|
||||||
|
|
||||||
|
init(auth: Authentication, version: String) {
|
||||||
|
self.id = UUID().uuidString
|
||||||
|
self.auth = auth
|
||||||
|
switch version {
|
||||||
|
case "1.0":
|
||||||
|
self.api = CookbookApiV1.self
|
||||||
|
default:
|
||||||
|
self.api = CookbookApiV1.self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Nextcloud Read Interface
|
||||||
|
extension NextcloudDataInterface: ReadInterface {
|
||||||
|
|
||||||
|
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
|
||||||
|
return await api.getImage(auth: auth.token, id: id, size: size).0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipeStubs() async -> [RecipeStub]? {
|
||||||
|
return await api.getRecipes(auth: auth.token).0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipe(id: String) async -> Recipe?{
|
||||||
|
return await api.getRecipe(auth: auth.token, id: id).0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCategories() async -> [Category]? {
|
||||||
|
return await api.getCategories(auth: auth.token).0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
|
||||||
|
return await api.getCategory(
|
||||||
|
auth: UserSettings.shared.authString,
|
||||||
|
named: categoryName
|
||||||
|
).0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTags() async -> [RecipeKeyword]? {
|
||||||
|
return await api.getTags(auth: auth.token).0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
|
||||||
|
return await api.getRecipesTagged(auth: auth.token, keyword: keyword).0
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Nextcloud Write Interface
|
||||||
|
extension NextcloudDataInterface: WriteInterface {
|
||||||
|
|
||||||
|
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postRecipe(recipe: Recipe) async -> (UserAlert?) {
|
||||||
|
return await api.createRecipe(auth: auth.token, recipe: recipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateRecipe(recipe: Recipe) async -> (UserAlert?) {
|
||||||
|
return await api.updateRecipe(auth: auth.token, recipe: recipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteRecipe(id: String) async -> (UserAlert?) {
|
||||||
|
return await api.deleteRecipe(auth: auth.token, id: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameCategory(named categoryName: String, newName: String) async -> (UserAlert?) {
|
||||||
|
return await api.renameCategory(auth: auth.token, named: categoryName, newName: newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
*/
|
||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
class RecipeExporter {
|
class RecipeExporter {
|
||||||
|
|
||||||
func createPDF(recipe: RecipeDetail, image: UIImage?) -> URL? {
|
func createPDF(recipe: CookbookApiRecipeDetailV1, image: UIImage?) -> URL? {
|
||||||
let document = PDFDocument(format: .a4)
|
let document = PDFDocument(format: .a4)
|
||||||
|
|
||||||
let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black)
|
let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black)
|
||||||
@@ -82,7 +82,7 @@ class RecipeExporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createText(recipe: RecipeDetail) -> String {
|
func createText(recipe: CookbookApiRecipeDetailV1) -> String {
|
||||||
var recipeString = ""
|
var recipeString = ""
|
||||||
recipeString.append("☛ " + recipe.name + "\n")
|
recipeString.append("☛ " + recipe.name + "\n")
|
||||||
recipeString.append(recipe.description + "\n\n")
|
recipeString.append(recipe.description + "\n\n")
|
||||||
@@ -99,7 +99,7 @@ class RecipeExporter {
|
|||||||
return recipeString
|
return recipeString
|
||||||
}
|
}
|
||||||
|
|
||||||
func createJson(recipe: RecipeDetail) -> Data? {
|
func createJson(recipe: CookbookApiRecipeDetailV1) -> Data? {
|
||||||
return JSONEncoder.safeEncode(recipe)
|
return JSONEncoder.safeEncode(recipe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
128
Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift
Normal file
128
Nextcloud Cookbook iOS Client/RecipeImport/RecipeScraper.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
//
|
||||||
|
// RecipeScraper.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 09.11.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftSoup
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeScraper {
|
||||||
|
func scrape(url: String) async throws -> (CookbookApiRecipeDetailV1?, RecipeImportAlert?) {
|
||||||
|
var contents: String? = nil
|
||||||
|
if let url = URL(string: url) {
|
||||||
|
do {
|
||||||
|
contents = try String(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
print("ERROR: Could not load url content.")
|
||||||
|
return (nil, .CHECK_CONNECTION)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
print("ERROR: Bad url.")
|
||||||
|
return (nil, .BAD_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let html = contents else {
|
||||||
|
print("ERROR: no contents")
|
||||||
|
return (nil, .WEBSITE_NOT_SUPPORTED)
|
||||||
|
}
|
||||||
|
let doc = try SwiftSoup.parse(html)
|
||||||
|
|
||||||
|
let elements: Elements = try doc.select("script")
|
||||||
|
for elem in elements.array() {
|
||||||
|
for attr in elem.getAttributes()!.asList() {
|
||||||
|
if attr.getValue() == "application/ld+json" {
|
||||||
|
guard let dict = toDict(elem) else { continue }
|
||||||
|
if let recipe = getRecipe(fromDict: dict) {
|
||||||
|
return (recipe, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (nil, .WEBSITE_NOT_SUPPORTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func toDict(_ elem: Element) -> [String: Any]? {
|
||||||
|
var recipeDict: [String: Any]? = nil
|
||||||
|
do {
|
||||||
|
let jsonString = try elem.html()
|
||||||
|
let json = try JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: .fragmentsAllowed)
|
||||||
|
if let recipe = json as? [String : Any] {
|
||||||
|
recipeDict = recipe
|
||||||
|
} else if let recipe = (json as! [Any])[0] as? [String : Any] {
|
||||||
|
recipeDict = recipe
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Unable to decode json")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let recipeDict = recipeDict else {
|
||||||
|
print("Json is not a dict")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if recipeDict["@type"] as? String ?? "" == "Recipe" {
|
||||||
|
return recipeDict
|
||||||
|
} else if (recipeDict["@type"] as? [String] ?? []).contains("Recipe") {
|
||||||
|
return recipeDict
|
||||||
|
} else {
|
||||||
|
print("Json dict is not a recipe ...")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getRecipe(fromDict recipe: Dictionary<String, Any>) -> CookbookApiRecipeDetailV1? {
|
||||||
|
|
||||||
|
var recipeDetail = CookbookApiRecipeDetailV1()
|
||||||
|
recipeDetail.name = recipe["name"] as? String ?? "New Recipe"
|
||||||
|
recipeDetail.recipeCategory = recipe["recipeCategory"] as? String ?? ""
|
||||||
|
recipeDetail.keywords = joinedStringForKey("keywords", dict: recipe)
|
||||||
|
recipeDetail.description = recipe["description"] as? String ?? ""
|
||||||
|
recipeDetail.dateCreated = recipe["dateCreated"] as? String ?? ""
|
||||||
|
recipeDetail.dateModified = recipe["dateModified"] as? String ?? ""
|
||||||
|
recipeDetail.imageUrl = recipe["imageUrl"] as? String ?? ""
|
||||||
|
recipeDetail.url = recipe["url"] as? String ?? ""
|
||||||
|
recipeDetail.cookTime = recipe["cookTime"] as? String ?? ""
|
||||||
|
recipeDetail.prepTime = recipe["prepTime"] as? String ?? ""
|
||||||
|
recipeDetail.totalTime = recipe["totalTime"] as? String ?? ""
|
||||||
|
recipeDetail.recipeInstructions = stringArrayForKey("recipeInstructions", dict: recipe)
|
||||||
|
recipeDetail.recipeYield = recipe["recipeYield"] as? Int ?? 0
|
||||||
|
recipeDetail.recipeIngredient = recipe["recipeIngredient"] as? [String] ?? []
|
||||||
|
recipeDetail.tool = stringArrayForKey("tool", dict: recipe)
|
||||||
|
recipeDetail.nutrition = recipe["nutrition"] as? [String:String] ?? [:]
|
||||||
|
print(recipeDetail)
|
||||||
|
return recipeDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stringArrayForKey(_ key: String, dict: Dictionary<String, Any>) -> [String] {
|
||||||
|
if let text = dict[key] as? String {
|
||||||
|
return [text]
|
||||||
|
} else if let value = dict[key] as? [String] {
|
||||||
|
return value
|
||||||
|
} else if let orderedList = dict[key] as? [Any] {
|
||||||
|
var entries: [String] = []
|
||||||
|
for dict in orderedList {
|
||||||
|
guard let dict = dict as? [String: Any] else { continue }
|
||||||
|
guard let text = dict["text"] as? String else { continue }
|
||||||
|
entries.append(text)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func joinedStringForKey(_ key: String, dict: Dictionary<String, Any>) -> String {
|
||||||
|
if let value = dict[key] as? [String] {
|
||||||
|
return value.joined(separator: ",")
|
||||||
|
} else if let value = dict[key] as? String {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import SwiftSoup
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
//let url = "https://www.chefkoch.de/rezepte/1385981243676608/Knusprige-Entenbrust.html"
|
||||||
|
let url = "https://www.allrecipes.com/recipe/234620/mascarpone-mashed-potatoes/"
|
||||||
|
|
||||||
|
let scraper = RecipeScaper()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
|
||||||
|
<timeline fileName='timeline.xctimeline'/>
|
||||||
|
</playground>
|
||||||
@@ -94,6 +94,33 @@ enum RecipeAlert: UserAlert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum RecipeImportAlert: UserAlert {
|
||||||
|
case BAD_URL,
|
||||||
|
CHECK_CONNECTION,
|
||||||
|
WEBSITE_NOT_SUPPORTED
|
||||||
|
|
||||||
|
var localizedDescription: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .BAD_URL: return "Please check the entered URL."
|
||||||
|
case .CHECK_CONNECTION: return "Unable to load website content. Please check your internet connection."
|
||||||
|
case .WEBSITE_NOT_SUPPORTED: return "This website might not be currently supported. If this appears incorrect, you can use the support options in the app settings to raise awareness about this issue."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localizedTitle: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .BAD_URL: return "Bad URL"
|
||||||
|
case .CHECK_CONNECTION: return "Connection error"
|
||||||
|
case .WEBSITE_NOT_SUPPORTED: return "Parsing error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var alertButtons: [AlertButton] {
|
||||||
|
return [.OK]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
enum RequestAlert: UserAlert {
|
enum RequestAlert: UserAlert {
|
||||||
case REQUEST_DELAYED,
|
case REQUEST_DELAYED,
|
||||||
REQUEST_DROPPED,
|
REQUEST_DROPPED,
|
||||||
@@ -119,3 +146,33 @@ enum RequestAlert: UserAlert {
|
|||||||
return [.OK]
|
return [.OK]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum PersistenceAlert: UserAlert {
|
||||||
|
case DECODING_FAILED,
|
||||||
|
ENCODING_FAILED,
|
||||||
|
SAVE_FAILED,
|
||||||
|
LOAD_FAILED
|
||||||
|
|
||||||
|
var localizedDescription: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .DECODING_FAILED: return "Unable to decode recipe data."
|
||||||
|
case .ENCODING_FAILED: return "Unable to encode recipe data."
|
||||||
|
case .SAVE_FAILED: return "Unable to save recipe."
|
||||||
|
case .LOAD_FAILED: return "Unable to load recipe."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var localizedTitle: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .DECODING_FAILED: return "Decoding Error"
|
||||||
|
case .ENCODING_FAILED: return "Encoding Error"
|
||||||
|
case .SAVE_FAILED: return "Error"
|
||||||
|
case .LOAD_FAILED: return "Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var alertButtons: [AlertButton] {
|
||||||
|
return [.OK]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,28 @@ class DurationComponents: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ hours: Int, _ min: Int, _ sec: Int = 0) {
|
||||||
|
self.hourComponent = hours
|
||||||
|
self.minuteComponent = min
|
||||||
|
self.secondComponent = sec
|
||||||
|
}
|
||||||
|
|
||||||
|
required init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let durationString = try container.decode(String.self)
|
||||||
|
let hourRegex = /([0-9]{1,2})H/
|
||||||
|
let minuteRegex = /([0-9]{1,2})M/
|
||||||
|
if let match = durationString.firstMatch(of: hourRegex) {
|
||||||
|
self.hourComponent = Int(match.1) ?? 0
|
||||||
|
}
|
||||||
|
if let match = durationString.firstMatch(of: minuteRegex) {
|
||||||
|
self.minuteComponent = Int(match.1) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var displayString: String {
|
var displayString: String {
|
||||||
if hourComponent != 0 && minuteComponent != 0 {
|
if hourComponent != 0 && minuteComponent != 0 {
|
||||||
@@ -144,3 +165,11 @@ class DurationComponents: ObservableObject {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension DurationComponents: Codable {
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
let durationString = toPTString()
|
||||||
|
try container.encode(durationString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,85 +6,45 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@StateObject var appState = AppState()
|
|
||||||
@StateObject var groceryList = GroceryListManager()
|
|
||||||
@StateObject var mealPlan = MealPlanManager()
|
|
||||||
|
|
||||||
// Tab ViewModels
|
// Tab ViewModels
|
||||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
|
||||||
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
|
||||||
|
|
||||||
@ObservedObject private var userSettings = UserSettings.shared
|
|
||||||
|
|
||||||
@State private var selectedTab: Tab = .recipes
|
|
||||||
|
|
||||||
@Binding var pendingImportURL: String?
|
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
case recipes, search, mealPlan, groceryList
|
case recipes, settings, groceryList
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView {
|
||||||
SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) {
|
|
||||||
RecipeTabView()
|
RecipeTabView()
|
||||||
.environmentObject(recipeViewModel)
|
.tabItem {
|
||||||
.environmentObject(appState)
|
Label("Recipes", systemImage: "book.closed.fill")
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
}
|
||||||
|
.tag(Tab.recipes)
|
||||||
|
|
||||||
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
|
||||||
SearchTabView()
|
|
||||||
.environmentObject(searchViewModel)
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
|
||||||
|
|
||||||
SwiftUI.Tab("Meal Plan", systemImage: "calendar", value: .mealPlan) {
|
|
||||||
MealPlanTabView()
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
}
|
|
||||||
|
|
||||||
if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue {
|
|
||||||
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) {
|
|
||||||
GroceryListTabView()
|
GroceryListTabView()
|
||||||
.environmentObject(groceryList)
|
.tabItem {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
Label("Grocery List", systemImage: "storefront")
|
||||||
|
} else {
|
||||||
|
Label("Grocery List", systemImage: "heart.text.square")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tag(Tab.groceryList)
|
||||||
|
|
||||||
|
SettingsTabView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Settings", systemImage: "gear")
|
||||||
}
|
}
|
||||||
.tabViewStyle(.sidebarAdaptable)
|
.tag(Tab.settings)
|
||||||
.modifier(TabBarMinimizeModifier())
|
|
||||||
.onChange(of: userSettings.groceryListMode) { _, newValue in
|
|
||||||
if newValue == GroceryListMode.appleReminders.rawValue && selectedTab == .groceryList {
|
|
||||||
selectedTab = .recipes
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await groceryList.load()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.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()
|
|
||||||
|
|
||||||
// Load category sorting data
|
|
||||||
await appState.loadCategoryAccessDates()
|
|
||||||
await appState.loadManualCategoryOrder()
|
|
||||||
|
|
||||||
// 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
|
||||||
@@ -97,30 +57,95 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await groceryList.load()
|
await groceryList.load()
|
||||||
groceryList.configureSyncManager(appState: appState)
|
|
||||||
if UserSettings.shared.grocerySyncEnabled {
|
|
||||||
await groceryList.syncManager?.performSync()
|
|
||||||
}
|
|
||||||
await mealPlan.load()
|
|
||||||
mealPlan.configureSyncManager(appState: appState)
|
|
||||||
if UserSettings.shared.mealPlanSyncEnabled {
|
|
||||||
await mealPlan.syncManager?.performSync()
|
|
||||||
}
|
|
||||||
recipeViewModel.presentLoadingIndicator = false
|
recipeViewModel.presentLoadingIndicator = false
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
.onChange(of: pendingImportURL) { _, newURL in
|
|
||||||
guard let url = newURL, !url.isEmpty else { return }
|
|
||||||
selectedTab = .recipes
|
|
||||||
recipeViewModel.pendingImportURL = url
|
|
||||||
// Dismiss any currently open import sheet before re-presenting
|
|
||||||
if recipeViewModel.showImportURLSheet {
|
|
||||||
recipeViewModel.showImportURLSheet = false
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
||||||
recipeViewModel.showImportURLSheet = true
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*struct CategoryListView: View {
|
||||||
|
@Bindable var cookbookState: CookbookState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(cookbookState.selectedAccountState.categories) { category in
|
||||||
|
NavigationLink {
|
||||||
|
RecipeListView(
|
||||||
|
cookbookState: cookbookState,
|
||||||
|
selectedCategory: category.name,
|
||||||
|
showEditView: .constant(false)
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if cookbookState.selectedAccountState.selectedCategory != nil &&
|
||||||
|
category.name == cookbookState.selectedAccountState.selectedCategory?.name {
|
||||||
|
Image(systemName: "book")
|
||||||
} else {
|
} else {
|
||||||
recipeViewModel.showImportURLSheet = true
|
Image(systemName: "book.closed.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
if category.name == "*" {
|
||||||
|
Text("Other")
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .default))
|
||||||
|
} else {
|
||||||
|
Text(category.name)
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .default))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Text("\(category.recipe_count)")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .default))
|
||||||
|
.foregroundStyle(Color.background)
|
||||||
|
.frame(width: 25, height: 25, alignment: .center)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.background {
|
||||||
|
Circle()
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
}.padding(7)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
/*struct CategoryListView: View {
|
||||||
|
@State var state: CookbookState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(selection: $state.categoryListSelection) {
|
||||||
|
ForEach(state.categories) { category in
|
||||||
|
NavigationLink(value: category) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if state.categoryListSelection != nil &&
|
||||||
|
category.name == state.categoryListSelection {
|
||||||
|
Image(systemName: "book")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
if category.name == "*" {
|
||||||
|
Text("Other")
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .default))
|
||||||
|
} else {
|
||||||
|
Text(category.name)
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .default))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Text("\(category.recipe_count)")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .default))
|
||||||
|
.foregroundStyle(Color.background)
|
||||||
|
.frame(width: 25, height: 25, alignment: .center)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.background {
|
||||||
|
Circle()
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
}.padding(7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}*/
|
||||||
|
|||||||
@@ -6,63 +6,44 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
/*
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@State var loginMethod: LoginMethod = .v2
|
@State var selectedTab: Int = 0
|
||||||
|
|
||||||
// Login error alert
|
|
||||||
@State var showAlert: Bool = false
|
|
||||||
@State var alertMessage: String = String(localized: "Error: Could not connect to server.")
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
TabView(selection: $selectedTab) {
|
||||||
VStack(spacing: 0) {
|
WelcomeTab().tag(0)
|
||||||
|
LoginTab().tag(1)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page)
|
||||||
|
.background(
|
||||||
|
selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
|
||||||
|
)
|
||||||
|
.animation(.easeInOut, value: selectedTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WelcomeTab: View {
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .center) {
|
||||||
|
Spacer()
|
||||||
Image("cookbook-icon")
|
Image("cookbook-icon")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 120, height: 120)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.padding(.top, 48)
|
Text("Thank you for downloading")
|
||||||
|
.font(.headline)
|
||||||
Text("Cookbook Client")
|
Text("Cookbook Client")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.bold()
|
.bold()
|
||||||
.padding(.top, 10)
|
Spacer()
|
||||||
Text("Thanks for downloading! Sign in to your Nextcloud server to get started.")
|
Text("This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings.")
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, 32)
|
|
||||||
.padding(.top, 6)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
Picker("Login Method", selection: $loginMethod) {
|
|
||||||
Text("Nextcloud Login").tag(LoginMethod.v2)
|
|
||||||
Text("App Token Login").tag(LoginMethod.token)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
|
|
||||||
if loginMethod == .token {
|
|
||||||
TokenLoginView(
|
|
||||||
showAlert: $showAlert,
|
|
||||||
alertMessage: $alertMessage
|
|
||||||
)
|
|
||||||
} else if loginMethod == .v2 {
|
|
||||||
V2LoginView(
|
|
||||||
showAlert: $showAlert,
|
|
||||||
alertMessage: $alertMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 28)
|
|
||||||
}
|
|
||||||
.fontDesign(.rounded)
|
|
||||||
.padding()
|
.padding()
|
||||||
.alert(alertMessage, isPresented: $showAlert) {
|
Spacer()
|
||||||
Button("Ok", role: .cancel) { }
|
|
||||||
}
|
}
|
||||||
}
|
.padding()
|
||||||
.background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea())
|
.fontDesign(.rounded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,45 +78,108 @@ enum TokenLoginStage: LoginStage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct LoginTab: View {
|
||||||
|
@State var loginMethod: LoginMethod = .v2
|
||||||
|
|
||||||
|
// Login error alert
|
||||||
|
@State var showAlert: Bool = false
|
||||||
|
@State var alertMessage: String = "Error: Could not connect to server."
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Spacer()
|
||||||
|
Picker("Login Method", selection: $loginMethod) {
|
||||||
|
Text("Nextcloud Login").tag(LoginMethod.v2)
|
||||||
|
Text("App Token Login").tag(LoginMethod.token)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding()
|
||||||
|
if loginMethod == .token {
|
||||||
|
TokenLoginView(
|
||||||
|
showAlert: $showAlert,
|
||||||
|
alertMessage: $alertMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if loginMethod == .v2 {
|
||||||
|
V2LoginView(
|
||||||
|
showAlert: $showAlert,
|
||||||
|
alertMessage: $alertMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
.padding()
|
||||||
|
.alert(alertMessage, isPresented: $showAlert) {
|
||||||
|
Button("Ok", role: .cancel) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct LoginLabel: View {
|
struct LoginLabel: View {
|
||||||
let text: LocalizedStringKey
|
let text: String
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.font(.subheadline)
|
.foregroundColor(.white)
|
||||||
.foregroundStyle(.secondary)
|
.font(.headline)
|
||||||
|
.padding(.vertical, 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BorderedLoginTextField: View {
|
struct BorderedLoginTextField: View {
|
||||||
var example: LocalizedStringKey
|
var example: String
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
|
@State var color: Color = .white
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TextField(example, text: $text)
|
TextField(example, text: $text)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.foregroundColor(color)
|
||||||
|
.accentColor(color)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(.white, lineWidth: 2)
|
||||||
|
.foregroundColor(.clear)
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoginTextField: View {
|
struct LoginTextField: View {
|
||||||
var example: LocalizedStringKey
|
var example: String
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
|
@State var color: Color = .white
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TextField(example, text: $text)
|
TextField(example, text: $text)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
|
.foregroundColor(color)
|
||||||
|
.accentColor(color)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.foregroundColor(Color.white.opacity(0.2))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct ServerAddressField: View {
|
struct ServerAddressField: View {
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
@State var serverProtocol: ServerProtocol = UserSettings.shared.serverProtocol == ServerProtocol.http.rawValue ? ServerProtocol.http : ServerProtocol.https
|
@State var serverProtocol: ServerProtocol = UserSettings.shared.serverProtocol == ServerProtocol.http.rawValue ? ServerProtocol.http : ServerProtocol.https
|
||||||
@@ -147,19 +191,19 @@ struct ServerAddressField: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading) {
|
||||||
LoginLabel(text: "Server address")
|
LoginLabel(text: "Server address")
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
Picker(ServerProtocol.https.rawValue, selection: $serverProtocol) {
|
Picker(ServerProtocol.https.rawValue, selection: $serverProtocol) {
|
||||||
ForEach(ServerProtocol.all, id: \.self) {
|
ForEach(ServerProtocol.all, id: \.self) {
|
||||||
Text($0.rawValue)
|
Text($0.rawValue)
|
||||||
}
|
}
|
||||||
}
|
}.pickerStyle(.menu)
|
||||||
.pickerStyle(.menu)
|
.tint(.white)
|
||||||
.tint(.accentColor)
|
.font(.headline)
|
||||||
.onChange(of: serverProtocol) { value in
|
.onChange(of: serverProtocol) { value in
|
||||||
Logger.view.debug("\(value.rawValue)")
|
print(value)
|
||||||
userSettings.serverProtocol = value.rawValue
|
userSettings.serverProtocol = value.rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,18 +211,27 @@ struct ServerAddressField: View {
|
|||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.padding(10)
|
.foregroundStyle(.white)
|
||||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
.padding()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.foregroundColor(Color.white.opacity(0.2))
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoginLabel(text: "Full server address")
|
||||||
|
.padding(.top)
|
||||||
Text(userSettings.serverProtocol + userSettings.serverAddress)
|
Text(userSettings.serverProtocol + userSettings.serverAddress)
|
||||||
.font(.footnote)
|
.foregroundColor(.white)
|
||||||
.foregroundStyle(.secondary)
|
.padding(.vertical, 5)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
.background(
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(.white, lineWidth: 2)
|
||||||
|
.foregroundColor(.clear)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,5 +241,7 @@ struct ServerAddressField_Preview: PreviewProvider {
|
|||||||
ServerAddressField()
|
ServerAddressField()
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
.padding()
|
.padding()
|
||||||
|
.background(Color.nextcloudBlue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -6,11 +6,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
struct TokenLoginView: View {
|
struct TokenLoginView: View {
|
||||||
@Binding var showAlert: Bool
|
@Binding var showAlert: Bool
|
||||||
@Binding var alertMessage: String
|
@Binding var alertMessage: String
|
||||||
@@ -26,25 +25,25 @@ struct TokenLoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading) {
|
||||||
ServerAddressField()
|
ServerAddressField()
|
||||||
|
.padding(.bottom)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
LoginLabel(text: "User name")
|
LoginLabel(text: "User name")
|
||||||
BorderedLoginTextField(example: "username", text: $userSettings.username)
|
BorderedLoginTextField(example: "username", text: $userSettings.username)
|
||||||
.focused($focusedField, equals: .username)
|
.focused($focusedField, equals: .username)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
}
|
.padding(.bottom)
|
||||||
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
LoginLabel(text: "App Token")
|
LoginLabel(text: "App Token")
|
||||||
BorderedLoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
|
BorderedLoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
|
||||||
.focused($focusedField, equals: .token)
|
.focused($focusedField, equals: .token)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.submitLabel(.join)
|
.submitLabel(.join)
|
||||||
}
|
HStack{
|
||||||
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
if await loginCheck(nextcloudLogin: false) {
|
if await loginCheck(nextcloudLogin: false) {
|
||||||
@@ -52,18 +51,19 @@ struct TokenLoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Submit", systemImage: "person.badge.key")
|
Text("Submit")
|
||||||
.font(.subheadline)
|
.foregroundColor(.white)
|
||||||
.fontWeight(.medium)
|
.font(.headline)
|
||||||
.frame(maxWidth: .infinity)
|
.padding()
|
||||||
.padding(.vertical, 10)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.fill(Color.nextcloudBlue.opacity(0.1))
|
.stroke(Color.white, lineWidth: 2)
|
||||||
|
.foregroundColor(.clear)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
switch focusedField {
|
switch focusedField {
|
||||||
@@ -72,31 +72,37 @@ struct TokenLoginView: View {
|
|||||||
case .username:
|
case .username:
|
||||||
focusedField = .token
|
focusedField = .token
|
||||||
default:
|
default:
|
||||||
Logger.view.debug("Attempting to log in ...")
|
print("Attempting to log in ...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginCheck(nextcloudLogin: Bool) async -> Bool {
|
func loginCheck(nextcloudLogin: Bool) async -> Bool {
|
||||||
if userSettings.serverAddress == "" {
|
if userSettings.serverAddress == "" {
|
||||||
alertMessage = String(localized: "Please enter a server address!")
|
alertMessage = "Please enter a server address!"
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return false
|
return false
|
||||||
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
|
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
|
||||||
alertMessage = String(localized: "Please enter a user name and app token!")
|
alertMessage = "Please enter a user name and app token!"
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
UserSettings.shared.setAuthString()
|
UserSettings.shared.setAuthString()
|
||||||
let client = CookbookApiFactory.makeClient()
|
let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString)
|
||||||
do {
|
|
||||||
let _ = try await client.getCategories()
|
if let error = error {
|
||||||
return true
|
alertMessage = "Login failed. Please check your inputs and internet connection."
|
||||||
} catch {
|
|
||||||
alertMessage = String(localized: "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,9 +6,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
|
import AuthenticationServices
|
||||||
|
|
||||||
|
|
||||||
|
protocol LoginStage {
|
||||||
|
func next() -> Self
|
||||||
|
func previous() -> Self
|
||||||
|
}
|
||||||
|
|
||||||
enum V2LoginStage: LoginStage {
|
enum V2LoginStage: LoginStage {
|
||||||
case login, validate
|
case login, validate
|
||||||
@@ -31,13 +37,28 @@ enum V2LoginStage: LoginStage {
|
|||||||
|
|
||||||
|
|
||||||
struct V2LoginView: View {
|
struct V2LoginView: View {
|
||||||
@Binding var showAlert: Bool
|
@Environment(\.dismiss) var dismiss
|
||||||
@Binding var alertMessage: String
|
@State var showAlert: Bool = false
|
||||||
|
@State var alertMessage: String = ""
|
||||||
|
|
||||||
@State var loginStage: V2LoginStage = .login
|
@State var loginStage: V2LoginStage = .login
|
||||||
@State var loginRequest: LoginV2Request? = nil
|
@State var loginRequest: LoginV2Request? = nil
|
||||||
@State var presentBrowser = false
|
@State var presentBrowser = false
|
||||||
|
|
||||||
|
@State var serverAddress: String = ""
|
||||||
|
@State var serverProtocol: ServerProtocol = .https
|
||||||
|
@State var loginPressed: Bool = false
|
||||||
|
@State var isLoading: Bool = false
|
||||||
|
|
||||||
|
// Task reference for polling, to cancel if needed
|
||||||
|
@State private var pollTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
|
enum ServerProtocol: String {
|
||||||
|
case https="https://", http="http://"
|
||||||
|
|
||||||
|
static let all = [https, http]
|
||||||
|
}
|
||||||
|
|
||||||
// TextField handling
|
// TextField handling
|
||||||
enum Field {
|
enum Field {
|
||||||
case server
|
case server
|
||||||
@@ -46,124 +67,219 @@ struct V2LoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack {
|
||||||
ServerAddressField()
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
CollapsibleView(titleColor: .secondary) {
|
dismiss()
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Make sure to enter the server address in the form 'example.com', or '<server address>:<port>' when a non-standard port is used.")
|
|
||||||
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there. After a successful login, return to this application and press 'Validate'.")
|
|
||||||
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
|
|
||||||
}
|
}
|
||||||
.font(.footnote)
|
Spacer()
|
||||||
|
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}.padding()
|
||||||
|
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Server address:")
|
||||||
|
TextField("example.com", text: $serverAddress)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("Server Protocol:", selection: $serverProtocol) {
|
||||||
|
ForEach(ServerProtocol.all, id: \.self) {
|
||||||
|
Text($0.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Login") {
|
||||||
|
initiateLoginV2()
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Nextcloud Login")
|
||||||
|
} footer: {
|
||||||
|
Text(
|
||||||
|
"""
|
||||||
|
The 'Login' button will open a web browser. Please follow the login instructions provided there.
|
||||||
|
After a successful login, return to this application and press 'Validate'.
|
||||||
|
If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
}.disabled(loginPressed)
|
||||||
|
|
||||||
|
if let loginRequest = loginRequest {
|
||||||
|
Section {
|
||||||
|
Text(loginRequest.login)
|
||||||
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} title: {
|
Button("Copy Link") {
|
||||||
Text("Show help")
|
UIPasteboard.general.string = loginRequest.login
|
||||||
.font(.subheadline)
|
|
||||||
}
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $presentBrowser) {
|
||||||
|
if let loginReq = loginRequest {
|
||||||
|
LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
|
||||||
|
switch result {
|
||||||
|
case .success(let url):
|
||||||
|
print("Login completed with URL: \(url)")
|
||||||
|
|
||||||
if loginRequest != nil {
|
dismiss()
|
||||||
Button {
|
case .failure(let error):
|
||||||
UIPasteboard.general.string = loginRequest!.login
|
print("Login failed: \(error.localizedDescription)")
|
||||||
} label: {
|
self.alertMessage = error.localizedDescription
|
||||||
Label("Copy Link", systemImage: "doc.on.doc")
|
self.isLoading = false
|
||||||
.font(.subheadline)
|
self.loginPressed = false
|
||||||
|
self.showAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Error: Login URL not available.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: $showAlert) {
|
||||||
|
Button("Copy Error") {
|
||||||
|
print("Error copied: \(alertMessage)")
|
||||||
|
UIPasteboard.general.string = alertMessage
|
||||||
|
isLoading = false
|
||||||
|
loginPressed = false
|
||||||
|
}
|
||||||
|
Button("Dismiss") {
|
||||||
|
print("Error dismissed.")
|
||||||
|
isLoading = false
|
||||||
|
loginPressed = false
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(alertMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 12) {
|
func initiateLoginV2() {
|
||||||
Button {
|
isLoading = true
|
||||||
if UserSettings.shared.serverAddress == "" {
|
loginPressed = true
|
||||||
alertMessage = String(localized: "Please enter a valid server address.")
|
|
||||||
showAlert = true
|
Task {
|
||||||
|
let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let (req, error) = await NextcloudApi.loginV2Request(baseAddress)
|
||||||
|
|
||||||
|
if let error = error {
|
||||||
|
self.alertMessage = error.localizedDescription
|
||||||
|
self.showAlert = true
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
guard let req = req else {
|
||||||
let error = await sendLoginV2Request()
|
self.alertMessage = "Failed to get login URL from server."
|
||||||
if let error = error {
|
self.showAlert = true
|
||||||
alertMessage = String(localized: "A network error occurred. Please try again.")
|
self.isLoading = false
|
||||||
showAlert = true
|
self.loginPressed = false
|
||||||
}
|
return
|
||||||
if let _ = loginRequest {
|
|
||||||
presentBrowser = true
|
|
||||||
} else {
|
|
||||||
alertMessage = String(localized: "Unable to reach server. Please check your server address and internet connection.")
|
|
||||||
showAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loginStage = loginStage.next()
|
|
||||||
} label: {
|
|
||||||
Label("Login", systemImage: "person.badge.key")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(Color.nextcloudBlue.opacity(0.1))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if loginStage == .validate {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
let (response, error) = await fetchLoginV2Response()
|
|
||||||
checkLogin(response: response, error: error)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Validate", systemImage: "checkmark.circle.fill")
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(Color.nextcloudBlue.opacity(0.1))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.disabled(loginRequest == nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $presentBrowser, onDismiss: {
|
|
||||||
Task {
|
|
||||||
let (response, error) = await fetchLoginV2Response()
|
|
||||||
checkLogin(response: response, error: error)
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
if let loginRequest = loginRequest {
|
|
||||||
WebViewSheet(url: loginRequest.login)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendLoginV2Request() async -> NetworkError? {
|
|
||||||
let (req, error) = await NextcloudApi.loginV2Request()
|
|
||||||
self.loginRequest = req
|
self.loginRequest = req
|
||||||
return error
|
|
||||||
|
// Present the browser session
|
||||||
|
presentBrowser = true
|
||||||
|
|
||||||
|
// Start polling in a separate task
|
||||||
|
startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
|
func startPolling(pollURL: String, pollToken: String) {
|
||||||
guard let loginRequest = loginRequest else { return (nil, .invalidRequest) }
|
// Cancel any existing poll task first
|
||||||
return await NextcloudApi.loginV2Response(req: loginRequest)
|
pollTask?.cancel()
|
||||||
|
var pollingFailed = true
|
||||||
|
|
||||||
|
pollTask = Task {
|
||||||
|
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
|
||||||
|
for _ in 0..<maxRetries {
|
||||||
|
if Task.isCancelled {
|
||||||
|
print("Task cancelled.")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
print("Task cancelled.")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if let response = response {
|
||||||
|
// Success
|
||||||
|
print("Task succeeded.")
|
||||||
|
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
|
||||||
|
pollingFailed = false
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.checkLogin(response: response, error: nil)
|
||||||
|
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if let error = error {
|
||||||
|
if case .clientError(statusCode: 404) = error {
|
||||||
|
// Continue polling
|
||||||
|
print("Polling unsuccessful, continuing.")
|
||||||
|
} else {
|
||||||
|
// A more serious error occurred during polling
|
||||||
|
print("Polling error: \(error.localizedDescription)")
|
||||||
|
await MainActor.run {
|
||||||
|
self.alertMessage = "Polling error: \(error.localizedDescription)"
|
||||||
|
self.showAlert = true
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If polling finishes without success
|
||||||
|
if !Task.isCancelled && pollingFailed {
|
||||||
|
await MainActor.run {
|
||||||
|
self.alertMessage = "Login timed out. Please try again."
|
||||||
|
self.showAlert = true
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
|
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))"
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let response = response else {
|
guard let response = response else {
|
||||||
alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
|
alertMessage = "Login failed. Please login via the browser and try again."
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Logger.network.debug("Login successful for user \(response.loginName)!")
|
print("Login successful for user \(response.loginName)!")
|
||||||
UserSettings.shared.username = response.loginName
|
UserSettings.shared.username = response.loginName
|
||||||
UserSettings.shared.token = response.appPassword
|
UserSettings.shared.token = response.appPassword
|
||||||
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||||
@@ -177,37 +293,72 @@ struct V2LoginView: View {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Login WebView logic
|
struct LoginBrowserView: UIViewControllerRepresentable {
|
||||||
|
let authURL: URL
|
||||||
|
let callbackURLScheme: String
|
||||||
|
var completion: (Result<URL, Error>) -> Void
|
||||||
|
|
||||||
struct WebViewSheet: View {
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
@Environment(\.dismiss) var dismiss
|
UIViewController()
|
||||||
@State var url: String
|
}
|
||||||
|
|
||||||
var body: some View {
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
||||||
NavigationStack {
|
|
||||||
WebView(url: URL(string: url)!)
|
if !context.coordinator.sessionStarted {
|
||||||
.navigationTitle("Nextcloud Login")
|
context.coordinator.sessionStarted = true
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
context.coordinator.sessionStarted = false // Reset for potential retry
|
||||||
Button("Done") {
|
if let callbackURL = callbackURL {
|
||||||
dismiss()
|
completion(.success(callbackURL))
|
||||||
|
} else if let error = error {
|
||||||
|
completion(.failure(error))
|
||||||
|
} else {
|
||||||
|
// Handle unexpected nil URL and error
|
||||||
|
completion(.failure(LoginError.unknownError))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session.presentationContextProvider = context.coordinator
|
||||||
|
|
||||||
|
session.prefersEphemeralWebBrowserSession = false
|
||||||
|
session.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
|
||||||
|
var parent: LoginBrowserView
|
||||||
|
var sessionStarted: Bool = false // Prevent starting multiple sessions
|
||||||
|
|
||||||
|
init(_ parent: LoginBrowserView) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
||||||
|
return windowScene.windows.first!
|
||||||
|
}
|
||||||
|
return ASPresentationAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoginError: Error, LocalizedError {
|
||||||
|
case unknownError
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unknownError: return "An unknown error occurred during login."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WebView: UIViewRepresentable {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
#Preview {
|
||||||
return WKWebView()
|
V2LoginView()
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: WKWebView, context: Context) {
|
|
||||||
let request = URLRequest(url: url)
|
|
||||||
uiView.load(request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
//
|
|
||||||
// AddToMealPlanSheet.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AddToMealPlanSheet: View {
|
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
let recipeId: String
|
|
||||||
let recipeName: String
|
|
||||||
let prepTime: String?
|
|
||||||
let recipeImage: UIImage?
|
|
||||||
|
|
||||||
@State private var weekOffset: Int = 0
|
|
||||||
@State private var selectedDays: Set<String> = []
|
|
||||||
|
|
||||||
private var calendar: Calendar { Calendar.current }
|
|
||||||
|
|
||||||
private var weekDates: [Date] {
|
|
||||||
let today = calendar.startOfDay(for: Date())
|
|
||||||
let weekday = calendar.component(.weekday, from: today)
|
|
||||||
let daysToMonday = (weekday + 5) % 7
|
|
||||||
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
|
||||||
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekLabel: String {
|
|
||||||
if weekOffset == 0 {
|
|
||||||
return String(localized: "This Week")
|
|
||||||
} else if weekOffset == 1 {
|
|
||||||
return String(localized: "Next Week")
|
|
||||||
} else if weekOffset == -1 {
|
|
||||||
return String(localized: "Last Week")
|
|
||||||
} else {
|
|
||||||
return weekRangeString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekRangeString: String {
|
|
||||||
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "dd.MM."
|
|
||||||
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Recipe header
|
|
||||||
recipeHeader
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Week navigation
|
|
||||||
weekNavigationHeader
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
// Day rows with checkboxes
|
|
||||||
List {
|
|
||||||
ForEach(weekDates, id: \.self) { date in
|
|
||||||
let dayStr = MealPlanDate.dayString(from: date)
|
|
||||||
let isAlreadyAssigned = mealPlan.isRecipeAssigned(recipeId, on: date)
|
|
||||||
let existingCount = mealPlan.entries(for: date).count
|
|
||||||
|
|
||||||
Button {
|
|
||||||
if !isAlreadyAssigned {
|
|
||||||
if selectedDays.contains(dayStr) {
|
|
||||||
selectedDays.remove(dayStr)
|
|
||||||
} else {
|
|
||||||
selectedDays.insert(dayStr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: (isAlreadyAssigned || selectedDays.contains(dayStr)) ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundStyle(isAlreadyAssigned ? Color.secondary : Color.nextcloudBlue)
|
|
||||||
|
|
||||||
Text(dayDisplayName(date))
|
|
||||||
.foregroundStyle(isAlreadyAssigned ? .secondary : .primary)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if existingCount > 0 {
|
|
||||||
Text("\(existingCount)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 2)
|
|
||||||
.background(Capsule().fill(Color(.tertiarySystemFill)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isAlreadyAssigned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
}
|
|
||||||
.navigationTitle("Schedule Recipe")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button("Done") {
|
|
||||||
let dates = selectedDays.compactMap { MealPlanDate.dateFromDay($0) }
|
|
||||||
if !dates.isEmpty {
|
|
||||||
mealPlan.assignRecipe(recipeId: recipeId, recipeName: recipeName, toDates: dates)
|
|
||||||
}
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.disabled(selectedDays.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var recipeHeader: some View {
|
|
||||||
HStack(spacing: 12) {
|
|
||||||
if let recipeImage {
|
|
||||||
Image(uiImage: recipeImage)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
} else {
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
.frame(width: 60, height: 60)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text(recipeName)
|
|
||||||
.font(.headline)
|
|
||||||
.lineLimit(2)
|
|
||||||
|
|
||||||
if let prepTime, !prepTime.isEmpty {
|
|
||||||
let duration = DurationComponents.fromPTString(prepTime)
|
|
||||||
if duration.hourComponent > 0 || duration.minuteComponent > 0 {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: "clock")
|
|
||||||
.font(.caption)
|
|
||||||
Text(duration.displayString)
|
|
||||||
.font(.caption)
|
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekNavigationHeader: some View {
|
|
||||||
HStack {
|
|
||||||
Button {
|
|
||||||
withAnimation { weekOffset -= 1 }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(weekLabel)
|
|
||||||
.font(.headline)
|
|
||||||
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
|
||||||
Text(weekRangeString)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation { weekOffset += 1 }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dayDisplayName(_ date: Date) -> String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "EEEE, d MMM"
|
|
||||||
let name = formatter.string(from: date)
|
|
||||||
if calendar.isDateInToday(date) {
|
|
||||||
return "\(name) (\(String(localized: "Today")))"
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
//
|
|
||||||
// AllRecipesCategoryCardView.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AllRecipesCategoryCardView: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@State private var mosaicImages: [UIImage] = []
|
|
||||||
|
|
||||||
private var totalRecipeCount: Int {
|
|
||||||
appState.categories.reduce(0) { $0 + $1.recipe_count }
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack(alignment: .bottomLeading) {
|
|
||||||
// 2x2 image mosaic or gradient fallback
|
|
||||||
if mosaicImages.count >= 4 {
|
|
||||||
mosaicGrid
|
|
||||||
} else {
|
|
||||||
gradientFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bottom scrim with text
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Spacer()
|
|
||||||
LinearGradient(
|
|
||||||
colors: [.clear, .black.opacity(0.95)],
|
|
||||||
startPoint: .top,
|
|
||||||
endPoint: .bottom
|
|
||||||
)
|
|
||||||
.frame(height: 60)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("All Recipes")
|
|
||||||
.font(.system(size: 16, weight: .medium))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.lineLimit(1)
|
|
||||||
Text("\(totalRecipeCount) 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))
|
|
||||||
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
|
||||||
.task {
|
|
||||||
await loadMosaicImages()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadMosaicImages() async {
|
|
||||||
// Ensure recipes are loaded for each category (they may not be yet)
|
|
||||||
for category in appState.categories {
|
|
||||||
if appState.recipes[category.name] == nil || appState.recipes[category.name]!.isEmpty {
|
|
||||||
await appState.getCategory(named: category.name, fetchMode: .preferLocal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all recipes across categories, shuffled for variety
|
|
||||||
var allRecipes: [Recipe] = []
|
|
||||||
for category in appState.categories {
|
|
||||||
if let recipes = appState.recipes[category.name] {
|
|
||||||
allRecipes.append(contentsOf: recipes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allRecipes.shuffle()
|
|
||||||
|
|
||||||
// Filter to recipes that have an image URL, then pick 4
|
|
||||||
// Prefer recipes not already used as category thumbnails
|
|
||||||
let categoryImageIds = appState.categoryImageRecipeIds
|
|
||||||
var candidates: [Recipe] = []
|
|
||||||
var fallbackCandidates: [Recipe] = []
|
|
||||||
var seenIds: Set<Int> = []
|
|
||||||
for recipe in allRecipes {
|
|
||||||
guard let url = recipe.imageUrl, !url.isEmpty else { continue }
|
|
||||||
guard !seenIds.contains(recipe.recipe_id) else { continue }
|
|
||||||
seenIds.insert(recipe.recipe_id)
|
|
||||||
if categoryImageIds.contains(recipe.recipe_id) {
|
|
||||||
fallbackCandidates.append(recipe)
|
|
||||||
} else {
|
|
||||||
candidates.append(recipe)
|
|
||||||
}
|
|
||||||
if candidates.count >= 4 { break }
|
|
||||||
}
|
|
||||||
// Fill remaining slots from fallback if needed
|
|
||||||
if candidates.count < 4 {
|
|
||||||
for recipe in fallbackCandidates {
|
|
||||||
candidates.append(recipe)
|
|
||||||
if candidates.count >= 4 { break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var images: [UIImage] = []
|
|
||||||
for recipe in candidates {
|
|
||||||
if let image = await appState.getImage(
|
|
||||||
id: recipe.recipe_id,
|
|
||||||
size: .THUMB,
|
|
||||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
|
||||||
) {
|
|
||||||
images.append(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard !images.isEmpty else { return }
|
|
||||||
// Cycle to fill 4 slots if fewer than 4 unique images
|
|
||||||
var filled: [UIImage] = []
|
|
||||||
for i in 0..<4 {
|
|
||||||
filled.append(images[i % images.count])
|
|
||||||
}
|
|
||||||
mosaicImages = filled
|
|
||||||
}
|
|
||||||
|
|
||||||
private var mosaicGrid: some View {
|
|
||||||
VStack(spacing: 1) {
|
|
||||||
HStack(spacing: 1) {
|
|
||||||
imageCell(mosaicImages[safe: 0])
|
|
||||||
imageCell(mosaicImages[safe: 1])
|
|
||||||
}
|
|
||||||
HStack(spacing: 1) {
|
|
||||||
imageCell(mosaicImages[safe: 2])
|
|
||||||
imageCell(mosaicImages[safe: 3])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
|
||||||
.clipped()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func imageCell(_ image: UIImage?) -> some View {
|
|
||||||
Group {
|
|
||||||
if let image {
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
|
||||||
.clipped()
|
|
||||||
} else {
|
|
||||||
Color.gray
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gradientFallback: some View {
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
|
||||||
.overlay(alignment: .center) {
|
|
||||||
Image(systemName: "square.grid.2x2.fill")
|
|
||||||
.font(.system(size: 36))
|
|
||||||
.foregroundStyle(.white.opacity(0.5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Array {
|
|
||||||
subscript(safe index: Int) -> Element? {
|
|
||||||
indices.contains(index) ? self[index] : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
//
|
|
||||||
// AllRecipesListView.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AllRecipesListView: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
@ObservedObject private var userSettings = UserSettings.shared
|
|
||||||
var onCreateNew: () -> Void
|
|
||||||
var onImportFromURL: () -> Void
|
|
||||||
@State private var allRecipes: [Recipe] = []
|
|
||||||
@State private var searchText: String = ""
|
|
||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
|
||||||
|
|
||||||
private var currentRecipeSortMode: RecipeSortMode {
|
|
||||||
RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Group {
|
|
||||||
let recipes = recipesFiltered()
|
|
||||||
if !recipes.isEmpty {
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("\(recipes.count) recipes")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
|
||||||
ForEach(recipes, id: \.recipe_id) { recipe in
|
|
||||||
NavigationLink(value: recipe) {
|
|
||||||
RecipeCardView(recipe: recipe)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
.padding(.vertical)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
VStack(spacing: 16) {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.system(size: 48))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("No recipes found")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
allRecipes = await appState.getRecipes()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.tint(.primary)
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
|
||||||
.navigationTitle(String(localized: "All Recipes"))
|
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
recipeSortMenu
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Menu {
|
|
||||||
Button {
|
|
||||||
onCreateNew()
|
|
||||||
} label: {
|
|
||||||
Label("Create New Recipe", systemImage: "square.and.pencil")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
onImportFromURL()
|
|
||||||
} label: {
|
|
||||||
Label("Import from URL", systemImage: "link")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "plus.circle.fill")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
allRecipes = await appState.getRecipes()
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
allRecipes = await appState.getRecipes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var recipeSortMenu: some View {
|
|
||||||
Menu {
|
|
||||||
ForEach(RecipeSortMode.allCases, id: \.self) { mode in
|
|
||||||
Button {
|
|
||||||
userSettings.recipeSortMode = mode.rawValue
|
|
||||||
userSettings.recipeSortAscending = true
|
|
||||||
} label: {
|
|
||||||
if currentRecipeSortMode == mode {
|
|
||||||
Label(mode.descriptor(), systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text(mode.descriptor())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
Button {
|
|
||||||
userSettings.recipeSortAscending.toggle()
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
userSettings.recipeSortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
|
|
||||||
systemImage: "arrow.up.arrow.down"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.up.arrow.down")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func recipesFiltered() -> [Recipe] {
|
|
||||||
let filtered: [Recipe]
|
|
||||||
if searchText.isEmpty {
|
|
||||||
filtered = allRecipes
|
|
||||||
} else {
|
|
||||||
filtered = allRecipes.filter { recipe in
|
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sortRecipes(filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sortRecipes(_ recipes: [Recipe]) -> [Recipe] {
|
|
||||||
let mode = currentRecipeSortMode
|
|
||||||
let ascending = userSettings.recipeSortAscending
|
|
||||||
switch mode {
|
|
||||||
case .recentlyAdded:
|
|
||||||
return recipes.sorted { a, b in
|
|
||||||
let dateA = parseDate(a.dateModified ?? a.dateCreated) ?? Date(timeIntervalSince1970: Double(a.recipe_id))
|
|
||||||
let dateB = parseDate(b.dateModified ?? b.dateCreated) ?? Date(timeIntervalSince1970: Double(b.recipe_id))
|
|
||||||
return ascending ? dateA > dateB : dateA < dateB
|
|
||||||
}
|
|
||||||
case .alphabetical:
|
|
||||||
return recipes.sorted { a, b in
|
|
||||||
let result = a.name.localizedCaseInsensitiveCompare(b.name)
|
|
||||||
return ascending ? result == .orderedAscending : result == .orderedDescending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let dateFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private func parseDate(_ string: String?) -> Date? {
|
|
||||||
guard let string, !string.isEmpty else { return nil }
|
|
||||||
// Try "yyyy-MM-dd HH:mm:ss" first
|
|
||||||
if let date = Self.dateFormatter.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
// Try Unix timestamp (integer string)
|
|
||||||
if let timestamp = Double(string), timestamp > 0 {
|
|
||||||
return Date(timeIntervalSince1970: timestamp)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
//
|
|
||||||
// CategoryReorderSheet.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CategoryReorderSheet: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
private static let allRecipesSentinel = "__ALL_RECIPES__"
|
|
||||||
|
|
||||||
@State private var orderedNames: [String] = []
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
List {
|
|
||||||
ForEach(orderedNames, id: \.self) { name in
|
|
||||||
HStack {
|
|
||||||
if name == Self.allRecipesSentinel {
|
|
||||||
Text("All Recipes")
|
|
||||||
.bold()
|
|
||||||
} else {
|
|
||||||
Text(name)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
if name == Self.allRecipesSentinel {
|
|
||||||
let total = appState.categories.reduce(0) { $0 + $1.recipe_count }
|
|
||||||
Text("\(total)")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.font(.subheadline)
|
|
||||||
} else if let count = appState.categories.first(where: { $0.name == name })?.recipe_count {
|
|
||||||
Text("\(count)")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.font(.subheadline)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onMove { from, to in
|
|
||||||
orderedNames.move(fromOffsets: from, toOffset: to)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.environment(\.editMode, .constant(.active))
|
|
||||||
.navigationTitle(String(localized: "Reorder Categories"))
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button(String(localized: "Cancel")) {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
|
||||||
Button(String(localized: "Done")) {
|
|
||||||
appState.updateManualCategoryOrder(orderedNames)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
.bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
let currentCategoryNames = appState.categories
|
|
||||||
.filter { $0.recipe_count > 0 }
|
|
||||||
.map { $0.name }
|
|
||||||
|
|
||||||
let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count }
|
|
||||||
|
|
||||||
let existing = appState.manualCategoryOrder
|
|
||||||
|
|
||||||
// Keep only names that still exist on the server (or are the sentinel)
|
|
||||||
var reconciled = existing.filter {
|
|
||||||
$0 == Self.allRecipesSentinel || currentCategoryNames.contains($0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the All Recipes sentinel is present
|
|
||||||
if totalCount > 0 && !reconciled.contains(Self.allRecipesSentinel) {
|
|
||||||
reconciled.insert(Self.allRecipesSentinel, at: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append any new categories not yet in the manual order
|
|
||||||
for name in currentCategoryNames where !reconciled.contains(name) {
|
|
||||||
reconciled.append(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
orderedNames = reconciled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
//
|
|
||||||
// ImportURLSheet.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ImportURLSheet: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
var onImport: (RecipeDetail) -> Void
|
|
||||||
var initialURL: String = ""
|
|
||||||
|
|
||||||
@State private var url: String = ""
|
|
||||||
@State private var isLoading: Bool = false
|
|
||||||
@State private var presentAlert: Bool = false
|
|
||||||
@State private var alertMessage: String = ""
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Form {
|
|
||||||
Section {
|
|
||||||
TextField("Recipe URL", text: $url)
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.textContentType(.URL)
|
|
||||||
.autocorrectionDisabled()
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
} footer: {
|
|
||||||
Text("Paste the URL of a recipe you would like to import.")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
await importRecipe()
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Spacer()
|
|
||||||
if isLoading {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Text("Import")
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Import Recipe")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("Import Failed", isPresented: $presentAlert) {
|
|
||||||
Button("OK", role: .cancel) { }
|
|
||||||
} message: {
|
|
||||||
Text(alertMessage)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if !initialURL.isEmpty {
|
|
||||||
url = initialURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func importRecipe() async {
|
|
||||||
isLoading = true
|
|
||||||
let (recipeDetail, error) = await appState.importRecipe(url: url)
|
|
||||||
isLoading = false
|
|
||||||
|
|
||||||
if let recipeDetail {
|
|
||||||
dismiss()
|
|
||||||
onImport(recipeDetail)
|
|
||||||
} else {
|
|
||||||
alertMessage = error?.localizedDescription ?? String(localized: "The recipe could not be imported. Please check the URL and try again.")
|
|
||||||
presentAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
//
|
|
||||||
// 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,75 +8,128 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
struct RecipeCardView: View {
|
struct RecipeCardView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
//@EnvironmentObject var appState: AppState
|
||||||
@State var recipe: Recipe
|
@State var recipe: Recipe
|
||||||
@State var recipeThumb: UIImage?
|
@State var recipeThumb: UIImage?
|
||||||
|
@State var isDownloaded: Bool? = nil
|
||||||
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 {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
HStack {
|
||||||
// Thumbnail
|
|
||||||
if let recipeThumb = recipeThumb {
|
if let recipeThumb = recipeThumb {
|
||||||
Image(uiImage: recipeThumb)
|
Image(uiImage: recipeThumb)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
|
.frame(width: 80, height: 80)
|
||||||
.clipped()
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
} else {
|
} else {
|
||||||
LinearGradient(
|
Image(systemName: "square.text.square")
|
||||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
.resizable()
|
||||||
startPoint: .topLeading,
|
.aspectRatio(contentMode: .fit)
|
||||||
endPoint: .bottomTrailing
|
.foregroundStyle(Color.white)
|
||||||
)
|
.padding(10)
|
||||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
|
.background(Color("ncblue"))
|
||||||
.overlay {
|
.frame(width: 80, height: 80)
|
||||||
Image(systemName: "fork.knife")
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
.font(.title2)
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Text content
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text(recipe.name)
|
Text(recipe.name)
|
||||||
.font(.subheadline)
|
.font(.headline)
|
||||||
.fontWeight(.medium)
|
.padding(.leading, 4)
|
||||||
.lineLimit(2)
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
|
|
||||||
if let keywordsText {
|
Spacer()
|
||||||
Text(keywordsText)
|
if let isDownloaded = isDownloaded {
|
||||||
.font(.caption2)
|
VStack {
|
||||||
.foregroundStyle(.secondary)
|
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
||||||
.lineLimit(1)
|
.foregroundColor(.secondary)
|
||||||
|
.padding()
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 8)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
}
|
}
|
||||||
.background(Color.backgroundHighlight)
|
.background(Color.backgroundHighlight)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
.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(
|
||||||
id: recipe.recipe_id,
|
id: recipe.recipe_id,
|
||||||
size: .THUMB,
|
size: .THUMB,
|
||||||
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
||||||
|
)*/
|
||||||
|
}
|
||||||
|
.frame(height: 80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
struct RecipeCardView: View {
|
||||||
|
@State var state: AccountState
|
||||||
|
@State var recipe: RecipeStub
|
||||||
|
@State var recipeThumb: UIImage?
|
||||||
|
@State var isDownloaded: Bool? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
if let recipeThumb = recipeThumb {
|
||||||
|
Image(uiImage: recipeThumb)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "square.text.square")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.padding(10)
|
||||||
|
.background(Color("ncblue"))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
|
}
|
||||||
|
Text(recipe.name)
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
if let isDownloaded = isDownloaded {
|
||||||
|
VStack {
|
||||||
|
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.backgroundHighlight)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
|
.task {
|
||||||
|
recipeThumb = await state.getImage(
|
||||||
|
id: recipe.id,
|
||||||
|
size: .THUMB
|
||||||
|
)
|
||||||
|
|
||||||
|
isDownloaded = recipe.storedLocally
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
recipeThumb = await state.getImage(
|
||||||
|
id: recipe.id,
|
||||||
|
size: .THUMB
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.frame(height: 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -7,101 +7,112 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
|
struct RecipeListView: View {
|
||||||
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Query var recipes: [Recipe]
|
||||||
|
@Binding var selectedRecipe: Recipe?
|
||||||
|
@Binding var selectedCategory: String?
|
||||||
|
|
||||||
|
init(selectedCategory: Binding<String?>, selectedRecipe: Binding<Recipe?>) {
|
||||||
|
var predicate: Predicate<Recipe>? = nil
|
||||||
|
|
||||||
|
if let category = selectedCategory.wrappedValue, category != "*" {
|
||||||
|
predicate = #Predicate<Recipe> {
|
||||||
|
$0.category == category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_recipes = Query(filter: predicate, sort: \.name)
|
||||||
|
_selectedRecipe = selectedRecipe
|
||||||
|
_selectedCategory = selectedCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(selection: $selectedRecipe) {
|
||||||
|
ForEach(recipes) { recipe in
|
||||||
|
RecipeCardView(recipe: recipe)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
.background(
|
||||||
|
NavigationLink(value: recipe) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.opacity(0)
|
||||||
|
)
|
||||||
|
.frame(height: 85)
|
||||||
|
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||||
|
.listRowSeparatorTint(.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Recipes")
|
||||||
|
.toolbar {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
struct RecipeListView: View {
|
struct RecipeListView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
@ObservedObject private var userSettings = UserSettings.shared
|
|
||||||
@State var categoryName: String
|
@State var categoryName: String
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
var onCreateNew: () -> Void
|
@Binding var showEditView: Bool
|
||||||
var onImportFromURL: () -> Void
|
@State var selectedRecipe: CookbookApiRecipeV1? = nil
|
||||||
@State var selectedRecipe: Recipe? = nil
|
|
||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
|
||||||
|
|
||||||
private var currentRecipeSortMode: RecipeSortMode {
|
|
||||||
RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
let recipes = recipesFiltered()
|
let recipes = recipesFiltered()
|
||||||
if !recipes.isEmpty {
|
if !recipes.isEmpty {
|
||||||
ScrollView {
|
List(recipesFiltered(), id: \.recipe_id) { recipe in
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("\(recipes.count) recipes")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.horizontal)
|
|
||||||
|
|
||||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
|
||||||
ForEach(recipes, id: \.recipe_id) { recipe in
|
|
||||||
NavigationLink(value: recipe) {
|
|
||||||
RecipeCardView(recipe: recipe)
|
RecipeCardView(recipe: recipe)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
.background(
|
||||||
|
NavigationLink(value: recipe) {
|
||||||
|
EmptyView()
|
||||||
}
|
}
|
||||||
.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(spacing: 16) {
|
VStack {
|
||||||
Image(systemName: "fork.knife")
|
Text("There are no recipes in this cookbook!")
|
||||||
.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: {
|
||||||
Label("Refresh", systemImage: "arrow.clockwise")
|
Text("Refresh")
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.primary)
|
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||||
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
||||||
|
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
recipeSortMenu
|
|
||||||
}
|
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Menu {
|
|
||||||
Button {
|
Button {
|
||||||
onCreateNew()
|
print("Add new recipe")
|
||||||
} label: {
|
showEditView = true
|
||||||
Label("Create New Recipe", systemImage: "square.and.pencil")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
onImportFromURL()
|
|
||||||
} label: {
|
|
||||||
Label("Import from URL", systemImage: "link")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
@@ -121,84 +132,95 @@ struct RecipeListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var recipeSortMenu: some View {
|
func recipesFiltered() -> [CookbookApiRecipeV1] {
|
||||||
Menu {
|
guard let recipes = appState.recipes[categoryName] else { return [] }
|
||||||
ForEach(RecipeSortMode.allCases, id: \.self) { mode in
|
guard searchText != "" else { return recipes }
|
||||||
Button {
|
return recipes.filter { recipe in
|
||||||
userSettings.recipeSortMode = mode.rawValue
|
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||||
userSettings.recipeSortAscending = true
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
||||||
} label: {
|
|
||||||
if currentRecipeSortMode == mode {
|
|
||||||
Label(mode.descriptor(), systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text(mode.descriptor())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
|
||||||
Divider()
|
struct RecipeListView: View {
|
||||||
Button {
|
@Bindable var cookbookState: CookbookState
|
||||||
userSettings.recipeSortAscending.toggle()
|
@State var selectedCategory: String
|
||||||
|
@State var searchText: String = ""
|
||||||
|
@Binding var showEditView: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
let recipes = recipesFiltered()
|
||||||
|
if !recipes.isEmpty {
|
||||||
|
|
||||||
|
List(recipesFiltered(), selection: $cookbookState.selectedAccountState.selectedRecipe) { recipe in
|
||||||
|
RecipeCardView(state: cookbookState.selectedAccountState, recipe: recipe)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
.background(
|
||||||
|
NavigationLink {
|
||||||
|
RecipeView(viewModel: RecipeView.ViewModel(recipeStub: recipe))
|
||||||
|
.environment(cookbookState)
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
EmptyView()
|
||||||
userSettings.recipeSortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
|
}
|
||||||
systemImage: "arrow.up.arrow.down"
|
.buttonStyle(.plain)
|
||||||
|
.opacity(0)
|
||||||
|
)
|
||||||
|
.frame(height: 85)
|
||||||
|
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||||
|
.listRowSeparatorTint(.clear)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
VStack {
|
||||||
|
Text("There are no recipes in this cookbook!")
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
let _ = await cookbookState.selectedAccountState.getCategories()
|
||||||
|
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: selectedCategory)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Refresh")
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||||
|
.navigationTitle(selectedCategory == "*" ? String(localized: "Other") : selectedCategory)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
print("Add new recipe")
|
||||||
|
showEditView = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
|
||||||
|
named: selectedCategory
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} label: {
|
.refreshable {
|
||||||
Image(systemName: "arrow.up.arrow.down")
|
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
|
||||||
|
named: selectedCategory
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recipesFiltered() -> [Recipe] {
|
func recipesFiltered() -> [RecipeStub] {
|
||||||
guard let recipes = appState.recipes[categoryName] else { return [] }
|
guard let recipes = cookbookState.selectedAccountState.recipeStubs[selectedCategory] else { return [] }
|
||||||
let filtered: [Recipe]
|
guard searchText != "" else { return recipes }
|
||||||
if searchText.isEmpty {
|
return recipes.filter { recipe in
|
||||||
filtered = recipes
|
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||||
} else {
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
||||||
filtered = recipes.filter { recipe in
|
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sortRecipes(filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sortRecipes(_ recipes: [Recipe]) -> [Recipe] {
|
|
||||||
let mode = currentRecipeSortMode
|
|
||||||
let ascending = userSettings.recipeSortAscending
|
|
||||||
switch mode {
|
|
||||||
case .recentlyAdded:
|
|
||||||
return recipes.sorted { a, b in
|
|
||||||
let dateA = parseDate(a.dateModified ?? a.dateCreated) ?? Date(timeIntervalSince1970: Double(a.recipe_id))
|
|
||||||
let dateB = parseDate(b.dateModified ?? b.dateCreated) ?? Date(timeIntervalSince1970: Double(b.recipe_id))
|
|
||||||
return ascending ? dateA > dateB : dateA < dateB
|
|
||||||
}
|
|
||||||
case .alphabetical:
|
|
||||||
return recipes.sorted { a, b in
|
|
||||||
let result = a.name.localizedCaseInsensitiveCompare(b.name)
|
|
||||||
return ascending ? result == .orderedAscending : result == .orderedDescending
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
private static let dateFormatter: DateFormatter = {
|
|
||||||
let f = DateFormatter()
|
|
||||||
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
||||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
|
||||||
return f
|
|
||||||
}()
|
|
||||||
|
|
||||||
private func parseDate(_ string: String?) -> Date? {
|
|
||||||
guard let string, !string.isEmpty else { return nil }
|
|
||||||
// Try "yyyy-MM-dd HH:mm:ss" first
|
|
||||||
if let date = Self.dateFormatter.date(from: string) {
|
|
||||||
return date
|
|
||||||
}
|
|
||||||
// Try Unix timestamp (integer string)
|
|
||||||
if let timestamp = Double(string), timestamp > 0 {
|
|
||||||
return Date(timeIntervalSince1970: timestamp)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,80 +11,34 @@ import SwiftUI
|
|||||||
// MARK: - RecipeView Duration Section
|
// MARK: - RecipeView Duration Section
|
||||||
|
|
||||||
struct RecipeDurationSection: View {
|
struct RecipeDurationSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
@State var presentPopover: Bool = false
|
@State var presentPopover: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
|
||||||
DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation"))
|
DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation"))
|
||||||
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
|
DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking"))
|
||||||
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
|
DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time"))
|
||||||
|
}
|
||||||
|
if editMode {
|
||||||
|
Button {
|
||||||
|
presentPopover.toggle()
|
||||||
|
} label: {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.padding(.top, 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
.popover(isPresented: $presentPopover) {
|
||||||
}
|
EditableDurationView(
|
||||||
|
prepTime: recipe.prepTimeDurationComponent,
|
||||||
// MARK: - Recipe Edit Duration Section (Form-based)
|
cookTime: recipe.cookTimeDurationComponent,
|
||||||
|
totalTime: recipe.totalTimeDurationComponent
|
||||||
struct RecipeEditDurationSection: View {
|
)
|
||||||
@ObservedObject var prepTime: DurationComponents
|
|
||||||
@ObservedObject var cookTime: DurationComponents
|
|
||||||
@ObservedObject var totalTime: DurationComponents
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section("Duration") {
|
|
||||||
DurationPickerRow(label: "Preparation", time: prepTime)
|
|
||||||
DurationPickerRow(label: "Cooking", time: cookTime)
|
|
||||||
DurationPickerRow(label: "Total time", time: totalTime)
|
|
||||||
}
|
|
||||||
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
|
|
||||||
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
|
|
||||||
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
|
|
||||||
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTotalTime() {
|
|
||||||
var hourComponent = prepTime.hourComponent + cookTime.hourComponent
|
|
||||||
var minuteComponent = prepTime.minuteComponent + cookTime.minuteComponent
|
|
||||||
if minuteComponent >= 60 {
|
|
||||||
hourComponent += minuteComponent / 60
|
|
||||||
minuteComponent %= 60
|
|
||||||
}
|
|
||||||
totalTime.hourComponent = hourComponent
|
|
||||||
totalTime.minuteComponent = minuteComponent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct DurationPickerRow: View {
|
|
||||||
let label: LocalizedStringKey
|
|
||||||
@ObservedObject var time: DurationComponents
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack {
|
|
||||||
Text(label)
|
|
||||||
Spacer()
|
|
||||||
Menu {
|
|
||||||
ForEach(0..<25, id: \.self) { hour in
|
|
||||||
Button("\(hour) h") {
|
|
||||||
time.hourComponent = hour
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("\(time.hourComponent) h")
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
Menu {
|
|
||||||
ForEach(0..<60, id: \.self) { minute in
|
|
||||||
Button("\(minute) min") {
|
|
||||||
time.minuteComponent = minute
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("\(time.minuteComponent) min")
|
|
||||||
.monospacedDigit()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,10 +95,10 @@ fileprivate struct EditableDurationView: View {
|
|||||||
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
|
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
|
.onChange(of: prepTime.hourComponent) { updateTotalTime() }
|
||||||
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
|
.onChange(of: prepTime.minuteComponent) { updateTotalTime() }
|
||||||
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
|
.onChange(of: cookTime.hourComponent) { updateTotalTime() }
|
||||||
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
|
.onChange(of: cookTime.minuteComponent) { updateTotalTime() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,3 +143,5 @@ fileprivate struct TimePickerView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct EditableListView: View {
|
|||||||
@State var axis: Axis = .vertical
|
@State var axis: Axis = .vertical
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationView {
|
||||||
ZStack {
|
ZStack {
|
||||||
List {
|
List {
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
@@ -101,15 +101,12 @@ struct EditableListView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(title)
|
.navigationBarTitle(title, displayMode: .inline)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarItems(
|
||||||
.toolbar {
|
trailing: Button(action: { isPresented = false }) {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Text("Done")
|
Text("Done")
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
|
||||||
.environment(\.editMode, .constant(.active))
|
.environment(\.editMode, .constant(.active))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
// MARK: - RecipeView Import Section
|
// MARK: - RecipeView Import Section
|
||||||
|
/*
|
||||||
struct RecipeImportSection: View {
|
struct RecipeImportSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@State var viewModel: RecipeView.ViewModel
|
||||||
var importRecipe: (String) async -> UserAlert?
|
var importRecipe: (String) async -> UserAlert?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -49,4 +49,4 @@ struct RecipeImportSection: View {
|
|||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -7,16 +7,43 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - RecipeView Ingredients Section
|
// MARK: - RecipeView Ingredients Section
|
||||||
|
|
||||||
struct RecipeIngredientSection: View {
|
struct RecipeIngredientSection: View {
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@Environment(\.modelContext) var modelContext
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@Binding var presentIngredientEditView: Bool
|
||||||
|
@State var recipeGroceries: RecipeGroceries? = nil
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
/*
|
||||||
|
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
|
||||||
|
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
|
||||||
|
} else {
|
||||||
|
cookbookState.groceryList.addItems(
|
||||||
|
viewModel.recipe.recipeIngredient,
|
||||||
|
toRecipe: viewModel.recipe.id,
|
||||||
|
recipeName: viewModel.recipe.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
Image(systemName: "storefront")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
}
|
||||||
|
}.disabled(editMode)
|
||||||
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -25,21 +52,30 @@ struct RecipeIngredientSection: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.bold()
|
.bold()
|
||||||
|
|
||||||
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
|
ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
|
|
||||||
IngredientListItem(
|
|
||||||
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
|
ForEach(0..<recipe.ingredients.count, id: \.self) { ix in
|
||||||
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
|
/*IngredientListItem(
|
||||||
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
|
ingredient: $recipe.recipeIngredient[ix],
|
||||||
recipeId: viewModel.observableRecipeDetail.id,
|
servings: $recipe.ingredientMultiplier,
|
||||||
recipeName: viewModel.observableRecipeDetail.name
|
recipeYield: Double(recipe.recipeYield),
|
||||||
)
|
recipeId: recipe.id
|
||||||
.padding(4)
|
) {
|
||||||
|
/*
|
||||||
|
cookbookState.groceryList.addItem(
|
||||||
|
recipe.recipeIngredient[ix],
|
||||||
|
toRecipe: recipe.id,
|
||||||
|
recipeName: recipe.name
|
||||||
|
)*/
|
||||||
|
}
|
||||||
|
.padding(4)*/
|
||||||
|
Text(recipe.ingredients[ix])
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
|
if recipe.ingredientMultiplier != Double(recipe.yield) {
|
||||||
HStack() {
|
HStack() {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -48,117 +84,118 @@ struct RecipeIngredientSection: View {
|
|||||||
}.padding(.top)
|
}.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if editMode {
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
presentIngredientEditView.toggle()
|
||||||
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
|
||||||
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
|
|
||||||
} else {
|
|
||||||
groceryList.addItems(
|
|
||||||
viewModel.observableRecipeDetail.recipeIngredient,
|
|
||||||
toRecipe: viewModel.observableRecipeDetail.id,
|
|
||||||
recipeName: viewModel.observableRecipeDetail.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Text("Edit")
|
||||||
groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "Remove all from Grocery List" : "Add All to Grocery List",
|
}
|
||||||
systemImage: groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "cart.badge.minus" : "cart.badge.plus"
|
.buttonStyle(.borderedProminent)
|
||||||
)
|
|
||||||
.font(.subheadline)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.foregroundStyle(groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? Color.red : Color.green)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill((groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? Color.red : Color.green).opacity(0.1))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
|
.animation(.easeInOut, value: recipe.ingredientMultiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAllGroceryItems(_ itemNames: [String], inCategory categoryId: String, named name: String) {
|
||||||
|
do {
|
||||||
|
// Find or create the target category
|
||||||
|
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
|
||||||
|
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
|
||||||
|
|
||||||
|
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
|
||||||
|
// Delete category if it exists
|
||||||
|
modelContext.delete(existingCategory)
|
||||||
|
} else {
|
||||||
|
// Create the category if it doesn't exist
|
||||||
|
let newCategory = RecipeGroceries(id: categoryId, name: name)
|
||||||
|
modelContext.insert(newCategory)
|
||||||
|
|
||||||
|
// Add new GroceryItems to the category
|
||||||
|
for itemName in itemNames {
|
||||||
|
let newItem = GroceryItem(name: itemName, isChecked: false)
|
||||||
|
newCategory.items.append(newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
try modelContext.save()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error adding grocery items: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recipe Edit Ingredient Section (Form-based)
|
func toggleGroceryItem(_ itemName: String, inCategory categoryId: String, named name: String) {
|
||||||
|
do {
|
||||||
|
// Find or create the target category
|
||||||
|
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
|
||||||
|
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
|
||||||
|
|
||||||
struct RecipeEditIngredientSection: View {
|
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
|
||||||
@Binding var ingredients: [String]
|
// Delete item if it exists
|
||||||
|
if existingCategory.items.contains(where: { $0.name == itemName }) {
|
||||||
|
existingCategory.items.removeAll { $0.name == itemName }
|
||||||
|
|
||||||
var body: some View {
|
// Delete category if empty
|
||||||
Section {
|
if existingCategory.items.isEmpty {
|
||||||
ForEach(ingredients.indices, id: \.self) { index in
|
modelContext.delete(existingCategory)
|
||||||
HStack {
|
|
||||||
TextField("Ingredient", text: $ingredients[index])
|
|
||||||
Image(systemName: "line.3.horizontal")
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
existingCategory.items.append(GroceryItem(name: itemName, isChecked: false))
|
||||||
}
|
}
|
||||||
.onDelete { indexSet in
|
} else {
|
||||||
ingredients.remove(atOffsets: indexSet)
|
// Add the category if it doesn't exist
|
||||||
}
|
let newCategory = RecipeGroceries(id: categoryId, name: name)
|
||||||
.onMove { from, to in
|
modelContext.insert(newCategory)
|
||||||
ingredients.move(fromOffsets: from, toOffset: to)
|
|
||||||
|
// Add the item to the new category
|
||||||
|
newCategory.items.append(GroceryItem(name: itemName, isChecked: false))
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
try modelContext.save()
|
||||||
ingredients.append("")
|
} catch {
|
||||||
} label: {
|
print("Error adding grocery items: \(error.localizedDescription)")
|
||||||
Label("Add Ingredient", systemImage: "plus.circle.fill")
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Ingredients")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RecipeIngredientSection List Item
|
// MARK: - RecipeIngredientSection List Item
|
||||||
|
/*
|
||||||
fileprivate struct IngredientListItem: View {
|
fileprivate struct IngredientListItem: View {
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Bindable var recipeGroceries: RecipeGroceries
|
||||||
@Binding var ingredient: String
|
@Binding var ingredient: String
|
||||||
@Binding var servings: Double
|
@Binding var servings: Double
|
||||||
@State var recipeYield: Double
|
@State var recipeYield: Double
|
||||||
@State var recipeId: String
|
@State var recipeId: String
|
||||||
var recipeName: String
|
|
||||||
|
|
||||||
@State var modifiedIngredient: AttributedString = ""
|
@State var modifiedIngredient: AttributedString = ""
|
||||||
|
@State var isSelected: Bool = false
|
||||||
var unmodified: Bool {
|
var unmodified: Bool {
|
||||||
servings == Double(recipeYield) || servings == 0
|
servings == Double(recipeYield) || servings == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swipe state
|
// Drag animation
|
||||||
@State private var dragOffset: CGFloat = 0
|
@State private var dragOffset: CGFloat = 0
|
||||||
@State private var animationStartOffset: CGFloat = 0
|
@State private var animationStartOffset: CGFloat = 0
|
||||||
private let maxDragDistance: CGFloat = 80
|
let maxDragDistance = 50.0
|
||||||
private let swipeThreshold: CGFloat = 0.4
|
|
||||||
|
|
||||||
private var isInGroceryList: Bool {
|
|
||||||
groceryList.containsItem(at: recipeId, item: ingredient)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .leading) {
|
HStack(alignment: .top) {
|
||||||
// Swipe background
|
if recipeGroceries.items.contains(ingredient) {
|
||||||
if dragOffset > 0 {
|
if #available(iOS 17.0, *) {
|
||||||
Image(systemName: isInGroceryList ? "cart.badge.minus" : "cart.badge.plus")
|
Image(systemName: "storefront")
|
||||||
.font(.caption)
|
.foregroundStyle(Color.green)
|
||||||
.bold()
|
} else {
|
||||||
.foregroundStyle(.white)
|
Image(systemName: "heart.text.square")
|
||||||
.frame(width: dragOffset, alignment: .center)
|
.foregroundStyle(Color.green)
|
||||||
.frame(maxHeight: .infinity)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(isInGroceryList ? Color.red : Color.green)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ingredient row
|
} else if isSelected {
|
||||||
HStack(alignment: .center) {
|
Image(systemName: "checkmark.circle")
|
||||||
Text("•")
|
} else {
|
||||||
.foregroundStyle(.secondary)
|
Image(systemName: "circle")
|
||||||
|
}
|
||||||
if !unmodified && String(modifiedIngredient.characters) == ingredient {
|
if !unmodified && String(modifiedIngredient.characters) == ingredient {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
@@ -171,48 +208,45 @@ fileprivate struct IngredientListItem: View {
|
|||||||
Text(modifiedIngredient)
|
Text(modifiedIngredient)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
}
|
//.foregroundStyle(String(modifiedIngredient.characters) == ingredient ? .red : .primary)
|
||||||
if isInGroceryList {
|
|
||||||
Image(systemName: "cart")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.green)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.background(Color(.systemBackground))
|
.onChange(of: servings) { _, newServings in
|
||||||
.offset(x: dragOffset)
|
|
||||||
}
|
|
||||||
.clipped()
|
|
||||||
.onChange(of: servings) { newServings in
|
|
||||||
if recipeYield == 0 {
|
if recipeYield == 0 {
|
||||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings)
|
||||||
} else {
|
} else {
|
||||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings/recipeYield)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
isSelected.toggle()
|
||||||
|
}
|
||||||
|
.offset(x: dragOffset, y: 0)
|
||||||
|
.animation(.easeInOut, value: isSelected)
|
||||||
|
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { gesture in
|
.onChanged { gesture in
|
||||||
|
// Update drag offset as the user drags
|
||||||
if animationStartOffset == 0 {
|
if animationStartOffset == 0 {
|
||||||
animationStartOffset = gesture.translation.width
|
animationStartOffset = gesture.translation.width
|
||||||
}
|
}
|
||||||
let dragAmount = gesture.translation.width
|
let dragAmount = gesture.translation.width
|
||||||
let offset = min(dragAmount, maxDragDistance + pow(max(0, dragAmount - maxDragDistance), 0.7)) - animationStartOffset
|
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset
|
||||||
self.dragOffset = max(0, offset)
|
self.dragOffset = max(0, offset)
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { gesture in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if dragOffset > maxDragDistance * swipeThreshold {
|
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
||||||
if isInGroceryList {
|
if recipeGroceries.items.contains(ingredient) {
|
||||||
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
||||||
} else {
|
} else {
|
||||||
groceryList.addItem(
|
addToGroceryListAction()
|
||||||
ingredient,
|
|
||||||
toRecipe: recipeId,
|
|
||||||
recipeName: recipeName
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Animate back to original position
|
||||||
self.dragOffset = 0
|
self.dragOffset = 0
|
||||||
self.animationStartOffset = 0
|
self.animationStartOffset = 0
|
||||||
}
|
}
|
||||||
@@ -220,7 +254,7 @@ fileprivate struct IngredientListItem: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
struct ServingPickerView: View {
|
struct ServingPickerView: View {
|
||||||
@@ -247,9 +281,12 @@ struct ServingPickerView: View {
|
|||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedServingSize) { newValue in
|
.onChange(of: selectedServingSize) { _, newValue in
|
||||||
if newValue < 0 { selectedServingSize = 0 }
|
if newValue < 0 { selectedServingSize = 0 }
|
||||||
else if newValue > 100 { selectedServingSize = 100 }
|
else if newValue > 100 { selectedServingSize = 100 }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,62 +11,35 @@ import SwiftUI
|
|||||||
// MARK: - RecipeView Instructions Section
|
// MARK: - RecipeView Instructions Section
|
||||||
|
|
||||||
struct RecipeInstructionSection: View {
|
struct RecipeInstructionSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@Binding var presentInstructionEditView: Bool
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
|
ForEach(recipe.instructions.indices, id: \.self) { ix in
|
||||||
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
|
RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1)
|
||||||
|
}
|
||||||
|
if editMode {
|
||||||
|
Button {
|
||||||
|
presentInstructionEditView.toggle()
|
||||||
|
} label: {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
// MARK: - Recipe Edit Instruction Section (Form-based)
|
|
||||||
|
|
||||||
struct RecipeEditInstructionSection: View {
|
|
||||||
@Binding var instructions: [String]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section {
|
|
||||||
ForEach(instructions.indices, id: \.self) { index in
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
Text("\(index + 1).")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.monospacedDigit()
|
|
||||||
TextField("Step \(index + 1)", text: $instructions[index], axis: .vertical)
|
|
||||||
.lineLimit(1...10)
|
|
||||||
Image(systemName: "line.3.horizontal")
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { indexSet in
|
|
||||||
instructions.remove(atOffsets: indexSet)
|
|
||||||
}
|
|
||||||
.onMove { from, to in
|
|
||||||
instructions.move(fromOffsets: from, toOffset: to)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
instructions.append("")
|
|
||||||
} label: {
|
|
||||||
Label("Add Step", systemImage: "plus.circle.fill")
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Instructions")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeInstructionListItem: View {
|
fileprivate struct RecipeInstructionListItem: View {
|
||||||
@Binding var instruction: String
|
@Binding var instruction: String
|
||||||
@@ -87,3 +60,44 @@ fileprivate struct RecipeInstructionListItem: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RecipeInstructionSection_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
// Create a mock recipe
|
||||||
|
@State var mockRecipe = createRecipe()
|
||||||
|
|
||||||
|
// Create mock state variables for the @Binding properties
|
||||||
|
@State var mockEditMode = true
|
||||||
|
@State var mockPresentInstructionEditView = false
|
||||||
|
|
||||||
|
// Provide the mock data to the view
|
||||||
|
RecipeInstructionSection(
|
||||||
|
recipe: mockRecipe,
|
||||||
|
editMode: $mockEditMode,
|
||||||
|
presentInstructionEditView: $mockPresentInstructionEditView
|
||||||
|
)
|
||||||
|
.previewDisplayName("Instructions - Edit Mode")
|
||||||
|
|
||||||
|
RecipeInstructionSection(
|
||||||
|
recipe: mockRecipe,
|
||||||
|
editMode: $mockEditMode,
|
||||||
|
presentInstructionEditView: $mockPresentInstructionEditView
|
||||||
|
)
|
||||||
|
.previewDisplayName("Instructions - Read Only")
|
||||||
|
.environment(\.editMode, .constant(.inactive))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createRecipe() -> Recipe {
|
||||||
|
let recipe = Recipe()
|
||||||
|
recipe.name = "Mock Recipe"
|
||||||
|
recipe.instructions = [
|
||||||
|
"Step 1: Gather all ingredients and equipment.",
|
||||||
|
"Step 2: Preheat oven to 180°C (350°F) and prepare baking dish.",
|
||||||
|
"Step 3: Combine dry ingredients in a large bowl and mix thoroughly.",
|
||||||
|
"Step 4: In a separate bowl, whisk wet ingredients until smooth.",
|
||||||
|
"Step 5: Gradually add wet ingredients to dry ingredients, mixing until just combined. Do not overmix.",
|
||||||
|
"Step 6: Pour the mixture into the prepared baking dish and bake for 30-35 minutes, or until golden brown and a toothpick inserted into the center comes out clean.",
|
||||||
|
"Step 7: Let cool before serving. Enjoy!"
|
||||||
|
]
|
||||||
|
return recipe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - RecipeView Keyword Section
|
// MARK: - RecipeView Keyword Section
|
||||||
|
/*
|
||||||
struct RecipeKeywordSection: View {
|
struct RecipeKeywordSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@State var viewModel: RecipeView.ViewModel
|
||||||
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
|
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
|
||||||
Group {
|
Group {
|
||||||
if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode {
|
if !viewModel.recipe.keywords.isEmpty && !viewModel.editMode {
|
||||||
RecipeListSection(list: $viewModel.observableRecipeDetail.keywords)
|
RecipeListSection(list: $viewModel.recipe.keywords)
|
||||||
} else {
|
} else {
|
||||||
Text(LocalizedStringKey("No keywords."))
|
Text(LocalizedStringKey("No keywords."))
|
||||||
}
|
}
|
||||||
@@ -189,3 +189,4 @@ struct KeywordPickerView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Recipe Metadata Section
|
// MARK: - Recipe Metadata Section
|
||||||
|
/*
|
||||||
struct RecipeMetadataSection: View {
|
struct RecipeMetadataSection: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@Environment(CookbookState.self) var cookbookState
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@State var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
@State var keywords: [RecipeKeyword] = []
|
@State var keywords: [RecipeKeyword] = []
|
||||||
var categories: [String] {
|
var categories: [String] {
|
||||||
appState.categories.map({ category in category.name })
|
cookbookState.selectedAccountState.categories.map({ category in category.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@State var presentKeywordSheet: Bool = false
|
@State var presentKeywordSheet: Bool = false
|
||||||
@@ -28,13 +28,14 @@ struct RecipeMetadataSection: View {
|
|||||||
// Category
|
// Category
|
||||||
SecondaryLabel(text: "Category")
|
SecondaryLabel(text: "Category")
|
||||||
HStack {
|
HStack {
|
||||||
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory)
|
TextField("Category", text: $viewModel.recipe.recipeCategory)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
|
Picker("Choose", selection: $viewModel.recipe.recipeCategory) {
|
||||||
|
Text("").tag("")
|
||||||
ForEach(categories, id: \.self) { item in
|
ForEach(categories, id: \.self) { item in
|
||||||
Text(item == "*" ? String(localized: "Other") : item).tag(item)
|
Text(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
@@ -44,10 +45,10 @@ struct RecipeMetadataSection: View {
|
|||||||
// Keywords
|
// Keywords
|
||||||
SecondaryLabel(text: "Keywords")
|
SecondaryLabel(text: "Keywords")
|
||||||
|
|
||||||
if !viewModel.observableRecipeDetail.keywords.isEmpty {
|
if !viewModel.recipe.keywords.isEmpty {
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
|
ForEach(viewModel.recipe.keywords, id: \.self) { keyword in
|
||||||
Text(keyword)
|
Text(keyword)
|
||||||
.padding(5)
|
.padding(5)
|
||||||
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
|
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
|
||||||
@@ -69,11 +70,11 @@ struct RecipeMetadataSection: View {
|
|||||||
Button {
|
Button {
|
||||||
presentServingsPopover.toggle()
|
presentServingsPopover.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Text("\(viewModel.observableRecipeDetail.recipeYield) Serving(s)")
|
Text("\(viewModel.recipe.recipeYield) Serving(s)")
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}
|
}
|
||||||
.popover(isPresented: $presentServingsPopover) {
|
.popover(isPresented: $presentServingsPopover) {
|
||||||
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
|
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.recipe.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,84 +82,7 @@ struct RecipeMetadataSection: View {
|
|||||||
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
|
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
|
||||||
.padding([.horizontal, .bottom], 5)
|
.padding([.horizontal, .bottom], 5)
|
||||||
.sheet(isPresented: $presentKeywordSheet) {
|
.sheet(isPresented: $presentKeywordSheet) {
|
||||||
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
|
KeywordPickerView(title: "Keywords", searchSuggestions: cookbookState.selectedAccountState.keywords, selection: $viewModel.recipe.keywords)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Recipe Edit Metadata Section (Form-based)
|
|
||||||
|
|
||||||
struct RecipeEditMetadataSection: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
|
||||||
|
|
||||||
@State private var showNewCategoryAlert = false
|
|
||||||
@State private var newCategoryName = ""
|
|
||||||
|
|
||||||
private let newCategoryTag = "\0_new_category_"
|
|
||||||
|
|
||||||
var categories: [String] {
|
|
||||||
var list = appState.categories.map { $0.name }
|
|
||||||
let current = viewModel.observableRecipeDetail.recipeCategory
|
|
||||||
if !current.isEmpty && current != newCategoryTag && !list.contains(current) {
|
|
||||||
list.append(current)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section("Details") {
|
|
||||||
Picker("Category", selection: $viewModel.observableRecipeDetail.recipeCategory) {
|
|
||||||
ForEach(categories, id: \.self) { item in
|
|
||||||
Text(item == "*" ? String(localized: "Other") : item).tag(item)
|
|
||||||
}
|
|
||||||
Divider()
|
|
||||||
Text("New Category…").tag(newCategoryTag)
|
|
||||||
}
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
.onChange(of: viewModel.observableRecipeDetail.recipeCategory) { _, newValue in
|
|
||||||
if newValue == newCategoryTag {
|
|
||||||
newCategoryName = ""
|
|
||||||
showNewCategoryAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.alert("New Category", isPresented: $showNewCategoryAlert) {
|
|
||||||
TextField("Category name", text: $newCategoryName)
|
|
||||||
Button("Cancel", role: .cancel) {
|
|
||||||
viewModel.observableRecipeDetail.recipeCategory = "*"
|
|
||||||
}
|
|
||||||
Button("Add") {
|
|
||||||
let trimmed = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !trimmed.isEmpty {
|
|
||||||
viewModel.observableRecipeDetail.recipeCategory = trimmed
|
|
||||||
} else {
|
|
||||||
viewModel.observableRecipeDetail.recipeCategory = "*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Stepper("Servings: \(viewModel.observableRecipeDetail.recipeYield)", value: $viewModel.observableRecipeDetail.recipeYield, in: 1...99)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
viewModel.presentKeywordSheet = true
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Keywords")
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
Spacer()
|
|
||||||
if viewModel.observableRecipeDetail.keywords.isEmpty {
|
|
||||||
Text("None")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Text(viewModel.observableRecipeDetail.keywords.joined(separator: ", "))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,27 +121,27 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// MARK: - RecipeView More Information Section
|
// MARK: - RecipeView More Information Section
|
||||||
|
|
||||||
struct MoreInformationSection: View {
|
struct MoreInformationSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if let dateCreated = viewModel.recipeDetail.dateCreated {
|
if let dateCreated = recipe.dateCreated {
|
||||||
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
|
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
|
||||||
}
|
}
|
||||||
if let dateModified = viewModel.recipeDetail.dateModified {
|
if let dateModified = recipe.dateModified {
|
||||||
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
|
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
|
||||||
}
|
}
|
||||||
if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) {
|
if recipe.url != "", let url = URL(string: recipe.url ?? "") {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("URL:")
|
Text("URL:")
|
||||||
Link(destination: url) {
|
Link(destination: url) {
|
||||||
Text(viewModel.observableRecipeDetail.url)
|
Text(recipe.url ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ import SwiftUI
|
|||||||
// MARK: - RecipeView Nutrition Section
|
// MARK: - RecipeView Nutrition Section
|
||||||
|
|
||||||
struct RecipeNutritionSection: View {
|
struct RecipeNutritionSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if viewModel.editMode {
|
if editMode {
|
||||||
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||||
HStack {
|
HStack {
|
||||||
Text(nutrition.localizedDescription)
|
Text(nutrition.localizedDescription)
|
||||||
@@ -28,7 +29,7 @@ struct RecipeNutritionSection: View {
|
|||||||
} else if !nutritionEmpty() {
|
} else if !nutritionEmpty() {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||||
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
|
if let value = recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("\(nutrition.localizedDescription): \(value)")
|
Text("\(nutrition.localizedDescription): \(value)")
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
@@ -43,7 +44,7 @@ struct RecipeNutritionSection: View {
|
|||||||
}
|
}
|
||||||
} title: {
|
} title: {
|
||||||
HStack {
|
HStack {
|
||||||
if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] {
|
if let servingSize = recipe.nutrition["servingSize"] {
|
||||||
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
||||||
} else {
|
} else {
|
||||||
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
||||||
@@ -56,14 +57,14 @@ struct RecipeNutritionSection: View {
|
|||||||
|
|
||||||
func binding(for key: String) -> Binding<String> {
|
func binding(for key: String) -> Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] },
|
get: { recipe.nutrition[key, default: ""] },
|
||||||
set: { viewModel.observableRecipeDetail.nutrition[key] = $0 }
|
set: { recipe.nutrition[key] = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nutritionEmpty() -> Bool {
|
func nutritionEmpty() -> Bool {
|
||||||
for nutrition in Nutrition.allCases {
|
for nutrition in Nutrition.allCases {
|
||||||
if let _ = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
|
if let value = recipe.nutrition[nutrition.dictKey] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,34 +72,3 @@ struct RecipeNutritionSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recipe Edit Nutrition Section (Form-based)
|
|
||||||
|
|
||||||
struct RecipeEditNutritionSection: View {
|
|
||||||
@Binding var nutrition: [String: String]
|
|
||||||
|
|
||||||
@State private var isExpanded: Bool = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section {
|
|
||||||
DisclosureGroup("Nutrition Information", isExpanded: $isExpanded) {
|
|
||||||
ForEach(Nutrition.allCases, id: \.self) { item in
|
|
||||||
HStack {
|
|
||||||
Text(item.localizedDescription)
|
|
||||||
.lineLimit(1)
|
|
||||||
Spacer()
|
|
||||||
TextField("", text: nutritionBinding(for: item.dictKey))
|
|
||||||
.multilineTextAlignment(.trailing)
|
|
||||||
.frame(maxWidth: 150)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func nutritionBinding(for key: String) -> Binding<String> {
|
|
||||||
Binding(
|
|
||||||
get: { nutrition[key, default: ""] },
|
|
||||||
set: { nutrition[key] = $0 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import SwiftUI
|
|||||||
// MARK: - RecipeView Tool Section
|
// MARK: - RecipeView Tool Section
|
||||||
|
|
||||||
struct RecipeToolSection: View {
|
struct RecipeToolSection: View {
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@Binding var presentToolEditView: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -20,41 +22,20 @@ struct RecipeToolSection: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
|
RecipeListSection(list: $recipe.tools)
|
||||||
|
|
||||||
|
if editMode {
|
||||||
|
Button {
|
||||||
|
presentToolEditView.toggle()
|
||||||
|
} label: {
|
||||||
|
Text("Edit")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recipe Edit Tool Section (Form-based)
|
|
||||||
|
|
||||||
struct RecipeEditToolSection: View {
|
|
||||||
@Binding var tools: [String]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Section {
|
|
||||||
ForEach(tools.indices, id: \.self) { index in
|
|
||||||
HStack {
|
|
||||||
TextField("Tool", text: $tools[index])
|
|
||||||
Image(systemName: "line.3.horizontal")
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDelete { indexSet in
|
|
||||||
tools.remove(atOffsets: indexSet)
|
|
||||||
}
|
|
||||||
.onMove { from, to in
|
|
||||||
tools.move(fromOffsets: from, toOffset: to)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
tools.append("")
|
|
||||||
} label: {
|
|
||||||
Label("Add Tool", systemImage: "plus.circle.fill")
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Tools")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
struct ShareView: View {
|
struct ShareView: View {
|
||||||
@State var recipeDetail: RecipeDetail
|
@State var recipeDetail: CookbookApiRecipeDetailV1
|
||||||
@State var recipeImage: UIImage?
|
@State var recipeImage: UIImage?
|
||||||
@Binding var presentShareSheet: Bool
|
@Binding var presentShareSheet: Bool
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
@@ -56,16 +55,11 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -153,7 +147,7 @@ extension RecipeTimer {
|
|||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||||
try AVAudioSession.sharedInstance().setActive(true)
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
} catch {
|
} catch {
|
||||||
Logger.view.error("Failed to set audio session category. Error: \(error)")
|
print("Failed to set audio session category. Error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +158,7 @@ extension RecipeTimer {
|
|||||||
audioPlayer?.prepareToPlay()
|
audioPlayer?.prepareToPlay()
|
||||||
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
||||||
} catch {
|
} catch {
|
||||||
Logger.view.error("Error loading sound file: \(error)")
|
print("Error loading sound file: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,9 +180,9 @@ extension RecipeTimer {
|
|||||||
func requestNotificationPermissions() {
|
func requestNotificationPermissions() {
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
Logger.view.debug("Notification permission granted.")
|
print("Notification permission granted.")
|
||||||
} else if let error = error {
|
} else if let error = error {
|
||||||
Logger.view.error("Notification permission denied because: \(error.localizedDescription).")
|
print("Notification permission denied because: \(error.localizedDescription).")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +199,7 @@ extension RecipeTimer {
|
|||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { error in
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
Logger.view.error("Error scheduling notification: \(error)")
|
print("Error scheduling notification: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ struct CollapsibleView<C: View, T: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
//
|
|
||||||
// LiquidGlassModifiers.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 14.02.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct TabBarMinimizeModifier: ViewModifier {
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
content.tabBarMinimizeBehavior(.onScrollDown)
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BackgroundExtensionModifier: ViewModifier {
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
if #available(iOS 26, *) {
|
|
||||||
content.backgroundExtensionEffect()
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// ListVStack.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 29.05.25.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ListVStack<Element, HeaderContent: View, RowContent: View>: View {
|
||||||
|
@Binding var items: [Element]
|
||||||
|
let header: () -> HeaderContent
|
||||||
|
let rows: (Int, Binding<Element>) -> RowContent
|
||||||
|
|
||||||
|
init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding<Element>) -> RowContent) {
|
||||||
|
self._items = items
|
||||||
|
self.header = header
|
||||||
|
self.rows = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
header()
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(items.indices, id: \.self) { index in
|
||||||
|
rows(index, $items[index])
|
||||||
|
.padding(10)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.secondary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 15))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,28 +5,51 @@
|
|||||||
// Created by Vincent Meilinger on 15.09.23.
|
// Created by Vincent Meilinger on 15.09.23.
|
||||||
//
|
//
|
||||||
|
|
||||||
import EventKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
var body: some View {
|
||||||
|
Text("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*struct SettingsView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryListManager: GroceryListManager
|
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
@StateObject var viewModel = ViewModel()
|
@ObservedObject var viewModel = ViewModel()
|
||||||
@State private var reminderLists: [EKCalendar] = []
|
|
||||||
@State private var remindersPermission: EKAuthorizationStatus = .notDetermined
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if let avatarImage = viewModel.avatarImage {
|
||||||
|
Image(uiImage: avatarImage)
|
||||||
|
.resizable()
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
}
|
||||||
|
if let userData = viewModel.userData {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(userData.userDisplayName)
|
||||||
|
.font(.title)
|
||||||
|
.padding(.leading)
|
||||||
|
Text("Username: \(userData.userId)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.leading)
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Add actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
|
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
|
||||||
Text("None").tag("None")
|
Text("None").tag("None")
|
||||||
ForEach(appState.categories, id: \.name) { category in
|
ForEach(appState.categories, id: \.name) { category in
|
||||||
Text(category.name == "*" ? String(localized: "Other") : category.name).tag(category)
|
Text(category.name == "*" ? "Other" : category.name).tag(category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -35,66 +58,6 @@ struct SettingsView: View {
|
|||||||
Text("The selected cookbook will open on app launch by default.")
|
Text("The selected cookbook will open on app launch by default.")
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
|
||||||
Picker("Appearance", selection: $userSettings.appearanceMode) {
|
|
||||||
ForEach(AppearanceMode.allValues, id: \.self) { mode in
|
|
||||||
Text(mode.descriptor()).tag(mode.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} footer: {
|
|
||||||
Text("Choose whether the app follows the system appearance or always uses light or dark mode.")
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
|
||||||
Picker("Grocery list storage", selection: $userSettings.groceryListMode) {
|
|
||||||
ForEach(GroceryListMode.allValues, id: \.self) { mode in
|
|
||||||
Text(mode.descriptor()).tag(mode.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue {
|
|
||||||
if remindersPermission == .notDetermined {
|
|
||||||
Button("Grant Reminders Access") {
|
|
||||||
Task {
|
|
||||||
let granted = await groceryListManager.requestRemindersAccess()
|
|
||||||
remindersPermission = groceryListManager.remindersPermissionStatus
|
|
||||||
if granted {
|
|
||||||
reminderLists = groceryListManager.availableReminderLists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if remindersPermission == .denied || remindersPermission == .restricted {
|
|
||||||
Text("Reminders access was denied. Please enable it in System Settings to use this feature.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Button("Open Settings") {
|
|
||||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
||||||
UIApplication.shared.open(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if remindersPermission == .fullAccess {
|
|
||||||
Picker("Reminders list", selection: $userSettings.remindersListIdentifier) {
|
|
||||||
ForEach(reminderLists, id: \.calendarIdentifier) { list in
|
|
||||||
Text(list.title).tag(list.calendarIdentifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toggle(isOn: $userSettings.grocerySyncEnabled) {
|
|
||||||
Text("Sync grocery list across devices")
|
|
||||||
}
|
|
||||||
} header: {
|
|
||||||
Text("Grocery List")
|
|
||||||
} footer: {
|
|
||||||
if userSettings.grocerySyncEnabled {
|
|
||||||
Text("Grocery list state is synced via your Nextcloud server by storing it alongside recipe data.")
|
|
||||||
} else if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue {
|
|
||||||
Text("Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app.")
|
|
||||||
} else {
|
|
||||||
Text("Grocery items are stored locally on this device.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $userSettings.expandNutritionSection) {
|
Toggle(isOn: $userSettings.expandNutritionSection) {
|
||||||
Text("Expand nutrition section")
|
Text("Expand nutrition section")
|
||||||
@@ -176,7 +139,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Log out") {
|
Button("Log out") {
|
||||||
Logger.view.debug("Log out.")
|
print("Log out.")
|
||||||
viewModel.alertType = .LOG_OUT
|
viewModel.alertType = .LOG_OUT
|
||||||
viewModel.showAlert = true
|
viewModel.showAlert = true
|
||||||
|
|
||||||
@@ -184,7 +147,7 @@ struct SettingsView: View {
|
|||||||
.tint(.red)
|
.tint(.red)
|
||||||
|
|
||||||
Button("Delete local data") {
|
Button("Delete local data") {
|
||||||
Logger.view.debug("Clear cache.")
|
print("Clear cache.")
|
||||||
viewModel.alertType = .DELETE_CACHE
|
viewModel.alertType = .DELETE_CACHE
|
||||||
viewModel.showAlert = true
|
viewModel.showAlert = true
|
||||||
}
|
}
|
||||||
@@ -197,6 +160,13 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Acknowledgements")) {
|
Section(header: Text("Acknowledgements")) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
|
||||||
|
Link("SwiftSoup", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
|
||||||
|
}
|
||||||
|
}
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
||||||
Link("TPPDF", destination: url)
|
Link("TPPDF", destination: url)
|
||||||
@@ -219,16 +189,7 @@ struct SettingsView: View {
|
|||||||
Text(viewModel.alertType.getMessage())
|
Text(viewModel.alertType.getMessage())
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
remindersPermission = groceryListManager.remindersPermissionStatus
|
await viewModel.getUserData()
|
||||||
if remindersPermission == .fullAccess {
|
|
||||||
reminderLists = groceryListManager.availableReminderLists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: userSettings.groceryListMode) { _, _ in
|
|
||||||
remindersPermission = groceryListManager.remindersPermissionStatus
|
|
||||||
if remindersPermission == .fullAccess {
|
|
||||||
reminderLists = groceryListManager.availableReminderLists()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +209,9 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
|
@Published var avatarImage: UIImage? = nil
|
||||||
|
@Published var userData: UserData? = nil
|
||||||
|
|
||||||
@Published var showAlert: Bool = false
|
@Published var showAlert: Bool = false
|
||||||
fileprivate var alertType: SettingsAlert = .NONE
|
fileprivate var alertType: SettingsAlert = .NONE
|
||||||
|
|
||||||
@@ -272,9 +236,20 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getUserData() async {
|
||||||
|
let (data, _) = await NextcloudApi.getAvatar()
|
||||||
|
let (userData, _) = await NextcloudApi.getHoverCard()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.avatarImage = data
|
||||||
|
self.userData = userData
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|||||||
@@ -6,39 +6,87 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model class GroceryItem {
|
||||||
|
var name: String
|
||||||
|
var isChecked: Bool
|
||||||
|
|
||||||
|
init(name: String, isChecked: Bool) {
|
||||||
|
self.name = name
|
||||||
|
self.isChecked = isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model class RecipeGroceries: Identifiable {
|
||||||
|
var id: String
|
||||||
|
var name: String
|
||||||
|
@Relationship(deleteRule: .cascade) var items: [GroceryItem]
|
||||||
|
var multiplier: Double
|
||||||
|
|
||||||
|
init(id: String, name: String, items: [GroceryItem], multiplier: Double) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.items = items
|
||||||
|
self.multiplier = multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.items = []
|
||||||
|
self.multiplier = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct GroceryListTabView: View {
|
struct GroceryListTabView: View {
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Query var groceryList: [RecipeGroceries] = []
|
||||||
|
@State var newGroceries: String = ""
|
||||||
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if groceryList.groceryDict.isEmpty {
|
|
||||||
EmptyGroceryListView()
|
|
||||||
} else {
|
|
||||||
List {
|
List {
|
||||||
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
|
HStack(alignment: .top) {
|
||||||
Section {
|
TextEditor(text: $newGroceries)
|
||||||
ForEach(groceryList.groceryDict[key]!.items) { item in
|
.padding(4)
|
||||||
GroceryListItemView(item: item, toggleAction: {
|
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||||
groceryList.toggleItemChecked(item)
|
.stroke(Color.secondary).opacity(0.5))
|
||||||
groceryList.objectWillChange.send()
|
.focused($isFocused)
|
||||||
}, deleteAction: {
|
Button {
|
||||||
groceryList.deleteItem(item.name, fromRecipe: key)
|
if !newGroceries.isEmpty {
|
||||||
withAnimation {
|
let items = newGroceries
|
||||||
groceryList.objectWillChange.send()
|
.split(separator: "\n")
|
||||||
|
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
Task {
|
||||||
|
await addGroceryItems(items, toCategory: "Other", named: String(localized: "Other"))
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
newGroceries = ""
|
||||||
|
|
||||||
|
} label: {
|
||||||
|
Text("Add")
|
||||||
|
}
|
||||||
|
.disabled(newGroceries.isEmpty)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ForEach(groceryList, id: \.name) { category in
|
||||||
|
Section {
|
||||||
|
ForEach(category.items, id: \.self) { item in
|
||||||
|
GroceryListItemView(item: item)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
HStack {
|
HStack {
|
||||||
Text(groceryList.groceryDict[key]!.name)
|
Text(category.name)
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
groceryList.deleteGroceryRecipe(key)
|
modelContext.delete(category)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "trash")
|
Image(systemName: "trash")
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
@@ -46,29 +94,80 @@ struct GroceryListTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if groceryList.isEmpty {
|
||||||
|
Text("You're all set for cooking 🍓")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
.navigationTitle("Grocery List")
|
.navigationTitle("Grocery List")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
Button {
|
Button {
|
||||||
groceryList.deleteAll()
|
do {
|
||||||
|
try modelContext.delete(model: RecipeGroceries.self)
|
||||||
|
} catch {
|
||||||
|
print("Failed to delete all GroceryCategory models.")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Delete")
|
Text("Delete")
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addGroceryItems(_ itemNames: [String], toCategory categoryId: String, named name: String) async {
|
||||||
|
do {
|
||||||
|
// Find or create the target category
|
||||||
|
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
|
||||||
|
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
|
||||||
|
|
||||||
|
var targetCategory: RecipeGroceries?
|
||||||
|
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
|
||||||
|
targetCategory = existingCategory
|
||||||
|
} else {
|
||||||
|
// Create the category if it doesn't exist
|
||||||
|
let newCategory = RecipeGroceries(id: categoryId, name: name)
|
||||||
|
modelContext.insert(newCategory)
|
||||||
|
targetCategory = newCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let category = targetCategory else { return }
|
||||||
|
|
||||||
|
// Add new GroceryItems to the category
|
||||||
|
for itemName in itemNames {
|
||||||
|
let newItem = GroceryItem(name: itemName, isChecked: false)
|
||||||
|
category.items.append(newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("Error adding grocery items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteGroceryItems(at offsets: IndexSet, in category: RecipeGroceries) {
|
||||||
|
for index in offsets {
|
||||||
|
let itemToDelete = category.items[index]
|
||||||
|
modelContext.delete(itemToDelete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct GroceryListItemView: View {
|
fileprivate struct GroceryListItemView: View {
|
||||||
let item: GroceryRecipeItem
|
@Environment(\.modelContext) var modelContext
|
||||||
let toggleAction: () -> Void
|
@Bindable var item: GroceryItem
|
||||||
let deleteAction: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
@@ -84,155 +183,13 @@ fileprivate struct GroceryListItemView: View {
|
|||||||
}
|
}
|
||||||
.padding(5)
|
.padding(5)
|
||||||
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
||||||
.onTapGesture(perform: toggleAction)
|
.onTapGesture(perform: { item.isChecked.toggle() })
|
||||||
.animation(.easeInOut, value: item.isChecked)
|
.animation(.easeInOut, value: item.isChecked)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button(action: deleteAction) {
|
Button(action: { modelContext.delete(item) }) {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct EmptyGroceryListView: View {
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Text("You're all set for cooking 🍓")
|
|
||||||
.font(.headline)
|
|
||||||
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.navigationTitle("Grocery List")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Grocery List Logic
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryRecipe: Identifiable, Codable {
|
|
||||||
let name: String
|
|
||||||
var items: [GroceryRecipeItem]
|
|
||||||
|
|
||||||
init(name: String, items: [GroceryRecipeItem]) {
|
|
||||||
self.name = name
|
|
||||||
self.items = items
|
|
||||||
}
|
|
||||||
|
|
||||||
init(name: String, item: GroceryRecipeItem) {
|
|
||||||
self.name = name
|
|
||||||
self.items = [item]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryRecipeItem: Identifiable, Codable {
|
|
||||||
let name: String
|
|
||||||
var isChecked: Bool
|
|
||||||
|
|
||||||
init(_ name: String, isChecked: Bool = false) {
|
|
||||||
self.name = name
|
|
||||||
self.isChecked = isChecked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@MainActor class GroceryList: ObservableObject {
|
|
||||||
let dataStore: DataStore = DataStore()
|
|
||||||
@Published var groceryDict: [String: GroceryRecipe] = [:]
|
|
||||||
@Published var sortBySimilarity: Bool = false
|
|
||||||
|
|
||||||
|
|
||||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
|
|
||||||
Logger.view.debug("Adding item of recipe \(String(describing: recipeName))")
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if self.groceryDict[recipeId] != nil {
|
|
||||||
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
|
||||||
} else {
|
|
||||||
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
|
|
||||||
self.groceryDict[recipeId] = newRecipe
|
|
||||||
}
|
|
||||||
if saveGroceryDict {
|
|
||||||
self.save()
|
|
||||||
self.objectWillChange.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
|
||||||
for item in items {
|
|
||||||
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
|
|
||||||
}
|
|
||||||
save()
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
|
||||||
Logger.view.debug("Deleting item \(itemName)")
|
|
||||||
guard let recipe = groceryDict[recipeId] else { return }
|
|
||||||
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
|
||||||
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
|
||||||
if groceryDict[recipeId]!.items.isEmpty {
|
|
||||||
groceryDict.removeValue(forKey: recipeId)
|
|
||||||
}
|
|
||||||
save()
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteGroceryRecipe(_ recipeId: String) {
|
|
||||||
Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
|
|
||||||
groceryDict.removeValue(forKey: recipeId)
|
|
||||||
save()
|
|
||||||
objectWillChange.send()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteAll() {
|
|
||||||
Logger.view.debug("Deleting all grocery items")
|
|
||||||
groceryDict = [:]
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
|
||||||
Logger.view.debug("Item checked: \(groceryItem.name)")
|
|
||||||
groceryItem.isChecked.toggle()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsItem(at recipeId: String, item: String) -> Bool {
|
|
||||||
guard let recipe = groceryDict[recipeId] else { return false }
|
|
||||||
if recipe.items.contains(where: { $0.name == item }) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsRecipe(_ recipeId: String) -> Bool {
|
|
||||||
return groceryDict[recipeId] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func save() {
|
|
||||||
Task {
|
|
||||||
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func load() async {
|
|
||||||
do {
|
|
||||||
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
|
|
||||||
fromPath: "grocery_list.data"
|
|
||||||
) else { return }
|
|
||||||
self.groceryDict = groceryDict
|
|
||||||
} catch {
|
|
||||||
Logger.view.error("Unable to load grocery list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
//
|
|
||||||
// MealPlanTabView.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct MealPlanTabView: View {
|
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
|
||||||
|
|
||||||
@State private var weekOffset: Int = 0
|
|
||||||
@State private var addRecipeDate: Date? = nil
|
|
||||||
|
|
||||||
private var calendar: Calendar { Calendar.current }
|
|
||||||
|
|
||||||
private var weekDates: [Date] {
|
|
||||||
let today = calendar.startOfDay(for: Date())
|
|
||||||
// Find start of current week (Monday)
|
|
||||||
let weekday = calendar.component(.weekday, from: today)
|
|
||||||
let daysToMonday = (weekday + 5) % 7
|
|
||||||
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
|
||||||
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekLabel: String {
|
|
||||||
if weekOffset == 0 {
|
|
||||||
return String(localized: "This Week")
|
|
||||||
} else if weekOffset == 1 {
|
|
||||||
return String(localized: "Next Week")
|
|
||||||
} else if weekOffset == -1 {
|
|
||||||
return String(localized: "Last Week")
|
|
||||||
} else {
|
|
||||||
return weekRangeString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekRangeString: String {
|
|
||||||
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "dd.MM."
|
|
||||||
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
ScrollView {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
weekNavigationHeader
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
ForEach(weekDates, id: \.self) { date in
|
|
||||||
MealPlanDayRow(
|
|
||||||
date: date,
|
|
||||||
entries: mealPlan.entries(for: date),
|
|
||||||
isToday: calendar.isDateInToday(date),
|
|
||||||
onAdd: {
|
|
||||||
addRecipeDate = date
|
|
||||||
},
|
|
||||||
onRemove: { entry in
|
|
||||||
withAnimation {
|
|
||||||
mealPlan.removeRecipe(recipeId: entry.recipeId, fromDate: entry.dateString)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Meal Plan")
|
|
||||||
.refreshable {
|
|
||||||
await appState.getCategories()
|
|
||||||
for category in appState.categories {
|
|
||||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
|
||||||
}
|
|
||||||
if UserSettings.shared.mealPlanSyncEnabled {
|
|
||||||
await mealPlan.syncManager?.performSync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
|
||||||
.sheet(item: $addRecipeDate) { date in
|
|
||||||
RecipePickerForMealPlan(date: date)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
.environmentObject(appState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var weekNavigationHeader: some View {
|
|
||||||
HStack {
|
|
||||||
Button {
|
|
||||||
withAnimation { weekOffset -= 1 }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.left")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(weekLabel)
|
|
||||||
.font(.headline)
|
|
||||||
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
|
||||||
Text(weekRangeString)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
|
||||||
withAnimation { weekOffset += 1 }
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.title3)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Day Row
|
|
||||||
|
|
||||||
fileprivate struct MealPlanDayRow: View {
|
|
||||||
let date: Date
|
|
||||||
let entries: [MealPlanEntry]
|
|
||||||
let isToday: Bool
|
|
||||||
let onAdd: () -> Void
|
|
||||||
let onRemove: (MealPlanEntry) -> Void
|
|
||||||
|
|
||||||
private var dayNumber: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "d"
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dayName: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "EEE"
|
|
||||||
return formatter.string(from: date).uppercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: 12) {
|
|
||||||
// Day label
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(dayName)
|
|
||||||
.font(.caption2)
|
|
||||||
.fontWeight(.medium)
|
|
||||||
.foregroundStyle(isToday ? .white : .secondary)
|
|
||||||
Text(dayNumber)
|
|
||||||
.font(.title3)
|
|
||||||
.fontWeight(.semibold)
|
|
||||||
.foregroundStyle(isToday ? .white : .primary)
|
|
||||||
}
|
|
||||||
.frame(width: 44, height: 54)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.fill(isToday ? Color.nextcloudBlue : Color.clear)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Entry or add button
|
|
||||||
if let entry = entries.first, let recipeIdInt = Int(entry.recipeId) {
|
|
||||||
NavigationLink(value: Recipe(
|
|
||||||
name: entry.recipeName,
|
|
||||||
keywords: nil,
|
|
||||||
dateCreated: nil,
|
|
||||||
dateModified: nil,
|
|
||||||
imageUrl: nil,
|
|
||||||
imagePlaceholderUrl: nil,
|
|
||||||
recipe_id: recipeIdInt
|
|
||||||
)) {
|
|
||||||
MealPlanEntryCard(entry: entry, onRemove: {
|
|
||||||
onRemove(entry)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
} else if let entry = entries.first {
|
|
||||||
MealPlanEntryCard(entry: entry, onRemove: {
|
|
||||||
onRemove(entry)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Button(action: onAdd) {
|
|
||||||
Image(systemName: "plus")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
.frame(maxWidth: .infinity, minHeight: 44)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(Color.nextcloudBlue.opacity(0.15))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
.padding(.vertical, 8)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.padding(.leading, 68)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Entry Card
|
|
||||||
|
|
||||||
fileprivate struct MealPlanEntryCard: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
let entry: MealPlanEntry
|
|
||||||
let onRemove: () -> Void
|
|
||||||
|
|
||||||
@State private var recipeThumb: UIImage?
|
|
||||||
@State private var totalTimeText: String?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
if let recipeThumb {
|
|
||||||
Image(uiImage: recipeThumb)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 44)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
} else {
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
.frame(width: 44)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text(entry.recipeName)
|
|
||||||
.font(.subheadline)
|
|
||||||
.lineLimit(3)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
if let totalTimeText {
|
|
||||||
HStack(spacing: 3) {
|
|
||||||
Image(systemName: "clock")
|
|
||||||
.font(.caption2)
|
|
||||||
Text(totalTimeText)
|
|
||||||
.font(.caption2)
|
|
||||||
}
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
.padding(6)
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(Color(.secondarySystemBackground))
|
|
||||||
)
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
onRemove()
|
|
||||||
} label: {
|
|
||||||
Label("Remove", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
guard let recipeIdInt = Int(entry.recipeId) else { return }
|
|
||||||
recipeThumb = await appState.getImage(
|
|
||||||
id: recipeIdInt,
|
|
||||||
size: .THUMB,
|
|
||||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
|
||||||
)
|
|
||||||
if let detail = await appState.getRecipe(
|
|
||||||
id: recipeIdInt,
|
|
||||||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
|
||||||
) {
|
|
||||||
if let totalTime = detail.totalTime, let text = DurationComponents.ptToText(totalTime) {
|
|
||||||
totalTimeText = text
|
|
||||||
} else if let prepTime = detail.prepTime, let text = DurationComponents.ptToText(prepTime) {
|
|
||||||
totalTimeText = text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Recipe Picker Sheet
|
|
||||||
|
|
||||||
struct RecipePickerForMealPlan: View {
|
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
|
|
||||||
let date: Date
|
|
||||||
|
|
||||||
@State private var searchText = ""
|
|
||||||
@State private var allRecipes: [Recipe] = []
|
|
||||||
|
|
||||||
private var filteredRecipes: [Recipe] {
|
|
||||||
if searchText.isEmpty {
|
|
||||||
return allRecipes
|
|
||||||
}
|
|
||||||
return allRecipes.filter {
|
|
||||||
$0.name.localizedCaseInsensitiveContains(searchText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var dateLabel: String {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateStyle = .medium
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
List {
|
|
||||||
ForEach(filteredRecipes, id: \.recipe_id) { recipe in
|
|
||||||
Button {
|
|
||||||
mealPlan.assignRecipe(
|
|
||||||
recipeId: String(recipe.recipe_id),
|
|
||||||
recipeName: recipe.name,
|
|
||||||
toDates: [date]
|
|
||||||
)
|
|
||||||
dismiss()
|
|
||||||
} label: {
|
|
||||||
RecipePickerRow(recipe: recipe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle(dateLabel)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.searchable(text: $searchText, prompt: String(localized: "Search recipes"))
|
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
|
||||||
Button("Cancel") { dismiss() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
allRecipes = await appState.getRecipes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Recipe Picker Row
|
|
||||||
|
|
||||||
fileprivate struct RecipePickerRow: View {
|
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
let recipe: Recipe
|
|
||||||
@State private var recipeThumb: UIImage?
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 10) {
|
|
||||||
if let recipeThumb {
|
|
||||||
Image(uiImage: recipeThumb)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
.frame(width: 48, height: 48)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
} else {
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
.frame(width: 48, height: 48)
|
|
||||||
.overlay {
|
|
||||||
Image(systemName: "fork.knife")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(recipe.name)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.lineLimit(2)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
recipeThumb = await appState.getImage(
|
|
||||||
id: recipe.recipe_id,
|
|
||||||
size: .THUMB,
|
|
||||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Date Identifiable Extension
|
|
||||||
|
|
||||||
extension Date: @retroactive Identifiable {
|
|
||||||
public var id: TimeInterval { timeIntervalSince1970 }
|
|
||||||
}
|
|
||||||
@@ -6,346 +6,133 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
struct RecipeTabView: View {
|
struct RecipeTabView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
//@State var cookbookState: CookbookState = CookbookState()
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@Environment(\.modelContext) var modelContext
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
@Query var recipes: [Recipe]
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@State var categories: [(String, Int)] = []
|
||||||
@ObservedObject private var userSettings = UserSettings.shared
|
@State private var selectedRecipe: Recipe?
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@State private var selectedCategory: String? = "*"
|
||||||
|
|
||||||
@State private var showManualReorderSheet = false
|
|
||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
|
||||||
|
|
||||||
private static let allRecipesSentinel = "__ALL_RECIPES__"
|
|
||||||
|
|
||||||
private var allCategoryNames: [String] {
|
|
||||||
let names = appState.categories.filter { $0.recipe_count > 0 }.map { $0.name }
|
|
||||||
let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count }
|
|
||||||
guard totalCount > 0 else { return names }
|
|
||||||
return [Self.allRecipesSentinel] + names
|
|
||||||
}
|
|
||||||
|
|
||||||
private var sortedCategoryNames: [String] {
|
|
||||||
let names = allCategoryNames
|
|
||||||
guard let mode = CategorySortMode(rawValue: userSettings.categorySortMode) else {
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
let ascending = userSettings.categorySortAscending
|
|
||||||
switch mode {
|
|
||||||
case .recentlyUsed:
|
|
||||||
return names.sorted { a, b in
|
|
||||||
let dateA = appState.categoryAccessDates[a] ?? .distantPast
|
|
||||||
let dateB = appState.categoryAccessDates[b] ?? .distantPast
|
|
||||||
return ascending ? dateA > dateB : dateA < dateB
|
|
||||||
}
|
|
||||||
case .alphabetical:
|
|
||||||
return names.sorted { a, b in
|
|
||||||
let nameA = a == Self.allRecipesSentinel ? String(localized: "All Recipes") : a
|
|
||||||
let nameB = b == Self.allRecipesSentinel ? String(localized: "All Recipes") : b
|
|
||||||
let result = nameA.localizedCaseInsensitiveCompare(nameB)
|
|
||||||
return ascending ? result == .orderedAscending : result == .orderedDescending
|
|
||||||
}
|
|
||||||
case .manual:
|
|
||||||
let order = appState.manualCategoryOrder
|
|
||||||
return names.sorted { a, b in
|
|
||||||
let indexA = order.firstIndex(of: a) ?? Int.max
|
|
||||||
let indexB = order.firstIndex(of: b) ?? Int.max
|
|
||||||
return indexA < indexB
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var hasCategories: Bool {
|
|
||||||
appState.categories.contains { $0.recipe_count > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentSortMode: CategorySortMode {
|
|
||||||
CategorySortMode(rawValue: userSettings.categorySortMode) ?? .recentlyUsed
|
|
||||||
}
|
|
||||||
|
|
||||||
private var categorySortMenu: some View {
|
|
||||||
Menu {
|
|
||||||
ForEach(CategorySortMode.allCases, id: \.self) { mode in
|
|
||||||
Button {
|
|
||||||
userSettings.categorySortMode = mode.rawValue
|
|
||||||
userSettings.categorySortAscending = true
|
|
||||||
if mode == .manual && appState.manualCategoryOrder.isEmpty {
|
|
||||||
appState.updateManualCategoryOrder(allCategoryNames)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if currentSortMode == mode {
|
|
||||||
Label(mode.descriptor(), systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text(mode.descriptor())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentSortMode.supportsInvert {
|
|
||||||
Divider()
|
|
||||||
Button {
|
|
||||||
userSettings.categorySortAscending.toggle()
|
|
||||||
} label: {
|
|
||||||
Label(
|
|
||||||
userSettings.categorySortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
|
|
||||||
systemImage: userSettings.categorySortAscending ? "arrow.up.arrow.down" : "arrow.up.arrow.down"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentSortMode == .manual {
|
|
||||||
Divider()
|
|
||||||
Button {
|
|
||||||
showManualReorderSheet = true
|
|
||||||
} label: {
|
|
||||||
Label(String(localized: "Edit Order"), systemImage: "arrow.up.arrow.down.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.up.arrow.down")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
NavigationStack(path: $viewModel.sidebarPath) {
|
List(selection: $selectedCategory) {
|
||||||
ScrollView {
|
CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*")
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
.tag("*") // Tag nil to select all recipes
|
||||||
// Recently Viewed
|
|
||||||
if !appState.recentRecipes.isEmpty {
|
|
||||||
RecentRecipesSection()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Categories header with sort button
|
Section("Categories") {
|
||||||
if hasCategories {
|
ForEach(categories, id: \.0.self) { category in
|
||||||
HStack {
|
CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0)
|
||||||
Text("Categories")
|
.tag(category.0)
|
||||||
.font(.title2)
|
|
||||||
.bold()
|
|
||||||
Spacer()
|
|
||||||
categorySortMenu
|
|
||||||
}
|
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category grid
|
|
||||||
if !hasCategories {
|
|
||||||
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(sortedCategoryNames, id: \.self) { name in
|
|
||||||
if name == Self.allRecipesSentinel {
|
|
||||||
Button {
|
|
||||||
appState.trackCategoryAccess(Self.allRecipesSentinel)
|
|
||||||
viewModel.navigateToAllRecipes()
|
|
||||||
} label: {
|
|
||||||
AllRecipesCategoryCardView()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
} else if let category = appState.categories.first(where: { $0.name == name && $0.recipe_count > 0 }) {
|
|
||||||
Button {
|
|
||||||
appState.trackCategoryAccess(category.name)
|
|
||||||
if horizontalSizeClass == .compact {
|
|
||||||
viewModel.navigateToCategory(category)
|
|
||||||
} else {
|
|
||||||
viewModel.selectedCategory = category
|
|
||||||
viewModel.showAllRecipesInDetail = false
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
CategoryCardView(
|
|
||||||
category: category,
|
|
||||||
isSelected: !viewModel.showAllRecipesInDetail && viewModel.selectedCategory?.name == category.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.navigationTitle("Categories")
|
||||||
}
|
} content: {
|
||||||
}
|
RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe)
|
||||||
.padding(.vertical)
|
|
||||||
}
|
|
||||||
.navigationTitle("Recipes")
|
|
||||||
.toolbar {
|
|
||||||
RecipeTabViewToolBar()
|
|
||||||
}
|
|
||||||
.navigationDestination(for: SidebarDestination.self) { destination in
|
|
||||||
switch destination {
|
|
||||||
case .settings:
|
|
||||||
SettingsView()
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
case .newRecipe:
|
|
||||||
RecipeView(viewModel: {
|
|
||||||
let vm = RecipeView.ViewModel()
|
|
||||||
if let imported = viewModel.importedRecipeDetail {
|
|
||||||
vm.preloadedRecipeDetail = imported
|
|
||||||
}
|
|
||||||
return vm
|
|
||||||
}())
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
.onAppear {
|
|
||||||
viewModel.importedRecipeDetail = nil
|
|
||||||
}
|
|
||||||
case .category(let category):
|
|
||||||
RecipeListView(
|
|
||||||
categoryName: category.name,
|
|
||||||
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
||||||
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
||||||
)
|
|
||||||
.id(category.id)
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
case .allRecipes:
|
|
||||||
AllRecipesListView(
|
|
||||||
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
||||||
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
||||||
)
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} detail: {
|
} detail: {
|
||||||
NavigationStack {
|
// Use a conditional view based on selection
|
||||||
if viewModel.showAllRecipesInDetail {
|
if let selectedRecipe {
|
||||||
AllRecipesListView(
|
//RecipeDetailView(recipe: recipe) // Create a dedicated detail view
|
||||||
onCreateNew: { viewModel.navigateToNewRecipe() },
|
RecipeView(recipe: selectedRecipe, viewModel: RecipeView.ViewModel(recipe: selectedRecipe))
|
||||||
onImportFromURL: { viewModel.showImportURLSheet = true }
|
} else {
|
||||||
)
|
ContentUnavailableView("Select a Recipe", systemImage: "fork.knife.circle")
|
||||||
} else if let category = viewModel.selectedCategory {
|
|
||||||
RecipeListView(
|
|
||||||
categoryName: category.name,
|
|
||||||
onCreateNew: { viewModel.navigateToNewRecipe() },
|
|
||||||
onImportFromURL: { viewModel.showImportURLSheet = true }
|
|
||||||
)
|
|
||||||
.id(category.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.sheet(isPresented: $viewModel.showImportURLSheet, onDismiss: {
|
|
||||||
viewModel.pendingImportURL = nil
|
|
||||||
}) {
|
|
||||||
ImportURLSheet(
|
|
||||||
onImport: { recipeDetail in
|
|
||||||
viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail)
|
|
||||||
},
|
|
||||||
initialURL: viewModel.pendingImportURL ?? ""
|
|
||||||
)
|
|
||||||
.environmentObject(appState)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showManualReorderSheet) {
|
|
||||||
CategoryReorderSheet()
|
|
||||||
.environmentObject(appState)
|
|
||||||
}
|
|
||||||
.task {
|
.task {
|
||||||
let connection = await appState.checkServerConnection()
|
initCategories()
|
||||||
DispatchQueue.main.async {
|
return
|
||||||
viewModel.serverConnection = connection
|
do {
|
||||||
}
|
try modelContext.delete(model: Recipe.self)
|
||||||
}
|
} catch {
|
||||||
.refreshable {
|
print("Failed to delete recipes and categories.")
|
||||||
let connection = await appState.checkServerConnection()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
viewModel.serverConnection = connection
|
|
||||||
}
|
|
||||||
await appState.getCategories()
|
|
||||||
for category in appState.categories {
|
|
||||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
|
||||||
await appState.getCategoryImage(for: category.name)
|
|
||||||
}
|
|
||||||
if UserSettings.shared.mealPlanSyncEnabled {
|
|
||||||
await mealPlan.syncManager?.performSync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let categories = await CookbookApiV1.getCategories(auth: UserSettings.shared.authString).0 else { return }
|
||||||
|
for category in categories {
|
||||||
|
guard let recipeStubs = await CookbookApiV1.getCategory(auth: UserSettings.shared.authString, named: category.name).0 else { return }
|
||||||
|
for recipeStub in recipeStubs {
|
||||||
|
guard let recipe = await CookbookApiV1.getRecipe(auth: UserSettings.shared.authString, id: recipeStub.id).0 else { return }
|
||||||
|
modelContext.insert(recipe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SidebarDestination: Hashable {
|
}/*
|
||||||
case settings
|
.toolbar {
|
||||||
case newRecipe
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
case category(Category)
|
Button(action: {
|
||||||
case allRecipes
|
//cookbookState.showSettings = true
|
||||||
|
}) {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
|
||||||
|
func initCategories() {
|
||||||
|
// Load Categories
|
||||||
|
var categoryDict: [String: Int] = [:]
|
||||||
|
for recipe in recipes {
|
||||||
|
// Ensure "Uncategorized" is a valid category if used
|
||||||
|
if !recipe.category.isEmpty {
|
||||||
|
categoryDict[recipe.category, default: 0] += 1
|
||||||
|
} else {
|
||||||
|
categoryDict["Other", default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
categories = categoryDict.map {
|
||||||
|
($0.key, $0.value)
|
||||||
|
}.sorted { $0.0 < $1.0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var sidebarPath = NavigationPath()
|
@Published var presentEditView: Bool = false
|
||||||
|
@Published var presentSettingsView: Bool = false
|
||||||
|
|
||||||
@Published var presentLoadingIndicator: Bool = false
|
@Published var presentLoadingIndicator: Bool = false
|
||||||
@Published var presentConnectionPopover: Bool = false
|
@Published var presentConnectionPopover: Bool = false
|
||||||
@Published var serverConnection: Bool = false
|
@Published var serverConnection: Bool = false
|
||||||
|
|
||||||
@Published var selectedCategory: Category? = nil
|
|
||||||
@Published var showAllRecipesInDetail: Bool = false
|
|
||||||
|
|
||||||
@Published var showImportURLSheet: Bool = false
|
|
||||||
@Published var importedRecipeDetail: RecipeDetail? = nil
|
|
||||||
@Published var pendingImportURL: String? = nil
|
|
||||||
|
|
||||||
func navigateToSettings() {
|
|
||||||
sidebarPath.append(SidebarDestination.settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigateToNewRecipe() {
|
|
||||||
sidebarPath.append(SidebarDestination.newRecipe)
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigateToImportedRecipe(recipeDetail: RecipeDetail) {
|
|
||||||
importedRecipeDetail = recipeDetail
|
|
||||||
sidebarPath.append(SidebarDestination.newRecipe)
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigateToCategory(_ category: Category) {
|
|
||||||
selectedCategory = category
|
|
||||||
showAllRecipesInDetail = false
|
|
||||||
sidebarPath.append(SidebarDestination.category(category))
|
|
||||||
}
|
|
||||||
|
|
||||||
func navigateToAllRecipes() {
|
|
||||||
selectedCategory = nil
|
|
||||||
showAllRecipesInDetail = true
|
|
||||||
sidebarPath.append(SidebarDestination.allRecipes)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fileprivate struct CategoryListItem: View {
|
||||||
|
var category: String
|
||||||
|
var count: Int
|
||||||
|
var isSelected: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if isSelected {
|
||||||
|
Image(systemName: "book")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(category)
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .default))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Text("\(count)")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .default))
|
||||||
|
.foregroundStyle(Color.background)
|
||||||
|
.frame(width: 25, height: 25, alignment: .center)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.background {
|
||||||
|
Circle()
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
}.padding(7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
|
|
||||||
var body: some ToolbarContent {
|
var body: some ToolbarContent {
|
||||||
// Top left menu toolbar item
|
// Top left menu toolbar item
|
||||||
@@ -360,9 +147,6 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
}
|
}
|
||||||
await appState.updateAllRecipeDetails()
|
await appState.updateAllRecipeDetails()
|
||||||
if UserSettings.shared.mealPlanSyncEnabled {
|
|
||||||
await mealPlan.syncManager?.performSync()
|
|
||||||
}
|
|
||||||
viewModel.presentLoadingIndicator = false
|
viewModel.presentLoadingIndicator = false
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -371,7 +155,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.navigateToSettings()
|
viewModel.presentSettingsView = true
|
||||||
} label: {
|
} label: {
|
||||||
Text("Settings")
|
Text("Settings")
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
@@ -384,7 +168,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
// Server connection indicator
|
// Server connection indicator
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button {
|
Button {
|
||||||
Logger.view.debug("Check server connection")
|
print("Check server connection")
|
||||||
viewModel.presentConnectionPopover = true
|
viewModel.presentConnectionPopover = true
|
||||||
} label: {
|
} label: {
|
||||||
if viewModel.presentLoadingIndicator {
|
if viewModel.presentLoadingIndicator {
|
||||||
@@ -410,18 +194,9 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
|
|
||||||
// Create new recipes
|
// Create new recipes
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Menu {
|
|
||||||
Button {
|
Button {
|
||||||
Logger.view.debug("Add new recipe")
|
print("Add new recipe")
|
||||||
viewModel.navigateToNewRecipe()
|
viewModel.presentEditView = true
|
||||||
} label: {
|
|
||||||
Label("Create New Recipe", systemImage: "square.and.pencil")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
viewModel.showImportURLSheet = true
|
|
||||||
} label: {
|
|
||||||
Label("Import from URL", systemImage: "link")
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
@@ -429,3 +204,5 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -7,96 +7,17 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
/*
|
||||||
struct SearchTabView: View {
|
struct SearchTabView: View {
|
||||||
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var mealPlan: MealPlanManager
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
VStack {
|
||||||
let results = viewModel.recipesFiltered()
|
List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
||||||
|
RecipeCardView(recipe: recipe)
|
||||||
if viewModel.searchText.isEmpty {
|
.shadow(radius: 2)
|
||||||
// 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()
|
||||||
@@ -104,22 +25,17 @@ struct SearchTabView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.opacity(0)
|
.opacity(0)
|
||||||
)
|
)
|
||||||
.listRowInsets(EdgeInsets(top: 6, leading: 15, bottom: 6, trailing: 15))
|
.frame(height: 85)
|
||||||
|
.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: CookbookApiRecipeV1.self) { recipe in
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(mealPlan)
|
|
||||||
}
|
}
|
||||||
.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 {
|
||||||
@@ -132,117 +48,28 @@ struct SearchTabView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var allRecipes: [Recipe] = []
|
@Published var allRecipes: [CookbookApiRecipeV1] = []
|
||||||
@Published var searchText: String = ""
|
@Published var searchText: String = ""
|
||||||
@Published var searchMode: SearchMode = .name
|
@Published var searchMode: SearchMode = .name
|
||||||
@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() -> [CookbookApiRecipeV1] {
|
||||||
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()) ||
|
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
||||||
}
|
}
|
||||||
} 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
234
Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift
Normal file
234
Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
//
|
||||||
|
// SettingsTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 29.05.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct SettingsTabView: View {
|
||||||
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
|
|
||||||
|
@State private var avatarImage: UIImage?
|
||||||
|
@State private var userData: UserData?
|
||||||
|
|
||||||
|
@State private var showAlert: Bool = false
|
||||||
|
@State private var alertType: SettingsAlert = .NONE
|
||||||
|
|
||||||
|
@State private var presentLoginSheet: Bool = false
|
||||||
|
|
||||||
|
enum SettingsAlert {
|
||||||
|
case LOG_OUT,
|
||||||
|
DELETE_CACHE,
|
||||||
|
NONE
|
||||||
|
|
||||||
|
func getTitle() -> String {
|
||||||
|
switch self {
|
||||||
|
case .LOG_OUT: return "Log out"
|
||||||
|
case .DELETE_CACHE: return "Delete local data"
|
||||||
|
default: return "Please confirm your action."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMessage() -> String {
|
||||||
|
switch self {
|
||||||
|
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
|
||||||
|
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if userSettings.authString.isEmpty {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if let avatarImage = avatarImage {
|
||||||
|
Image(uiImage: avatarImage)
|
||||||
|
.resizable()
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
}
|
||||||
|
if let userData = userData {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(userData.userDisplayName)
|
||||||
|
.font(.title)
|
||||||
|
.padding(.leading)
|
||||||
|
Text("Username: \(userData.userId)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.leading)
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Add actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Log out") {
|
||||||
|
print("Log out.")
|
||||||
|
alertType = .LOG_OUT
|
||||||
|
showAlert = true
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
} else {
|
||||||
|
Button("Log in") {
|
||||||
|
print("Log in.")
|
||||||
|
presentLoginSheet.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Nextcloud")
|
||||||
|
} footer: {
|
||||||
|
Text("Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.expandNutritionSection) {
|
||||||
|
Text("Expand nutrition section")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.expandKeywordSection) {
|
||||||
|
Text("Expand keyword section")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.expandInfoSection) {
|
||||||
|
Text("Expand information section")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Recipes")
|
||||||
|
} footer: {
|
||||||
|
Text("Configure which sections in your recipes are expanded by default.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.keepScreenAwake) {
|
||||||
|
Text("Keep screen awake when viewing recipes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Decimal number format")
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $userSettings.decimalNumberSeparator) {
|
||||||
|
Text("Point (e.g. 1.42)").tag(".")
|
||||||
|
Text("Comma (e.g. 1,42)").tag(",")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.storeRecipes) {
|
||||||
|
Text("Offline recipes")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.storeImages) {
|
||||||
|
Text("Store recipe images locally")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.storeThumb) {
|
||||||
|
Text("Store recipe thumbnails locally")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Downloads")
|
||||||
|
} footer: {
|
||||||
|
Text("Configure what is stored on your device.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Language", selection: $userSettings.language) {
|
||||||
|
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
|
||||||
|
Text(lang.descriptor()).tag(lang.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
|
||||||
|
} header: {
|
||||||
|
Text("About")
|
||||||
|
} footer: {
|
||||||
|
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
|
||||||
|
} header: {
|
||||||
|
Text("Support")
|
||||||
|
} footer: {
|
||||||
|
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Delete local data") {
|
||||||
|
print("Clear cache.")
|
||||||
|
alertType = .DELETE_CACHE
|
||||||
|
showAlert = true
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Other")
|
||||||
|
} footer: {
|
||||||
|
Text("Deleting local data will not affect the recipe data stored on your server.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Acknowledgements")) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
|
||||||
|
Link("SwiftSoup", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
||||||
|
Link("TPPDF", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.alert(alertType.getTitle(), isPresented: $showAlert) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
if alertType == .DELETE_CACHE {
|
||||||
|
Button("Delete", role: .destructive) { deleteCachedData() }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(alertType.getMessage())
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await getUserData()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $presentLoginSheet, onDismiss: {}) {
|
||||||
|
V2LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserData() async {
|
||||||
|
let (data, _) = await NextcloudApi.getAvatar()
|
||||||
|
let (userData, _) = await NextcloudApi.getHoverCard()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.avatarImage = data
|
||||||
|
self.userData = userData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCachedData() {
|
||||||
|
print("TODO: Delete cached data\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict/>
|
||||||
<key>CFBundleURLTypes</key>
|
|
||||||
<array>
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>com.vincentmeilinger.nextcloud-cookbook</string>
|
|
||||||
<key>CFBundleURLSchemes</key>
|
|
||||||
<array>
|
|
||||||
<string>nextcloud-cookbook</string>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</array>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ You can download the app from the AppStore:
|
|||||||
|
|
||||||
- [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
|
- [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
|
||||||
|
|
||||||
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code. Update: I will continue to work on this update in January 2024.
|
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code.
|
||||||
|
|
||||||
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
|
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>NSExtension</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionAttributes</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionActivationRule</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
|
||||||
<integer>1</integer>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
<key>NSExtensionPointIdentifier</key>
|
|
||||||
<string>com.apple.share-services</string>
|
|
||||||
<key>NSExtensionPrincipalClass</key>
|
|
||||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
//
|
|
||||||
// ShareViewController.swift
|
|
||||||
// ShareExtension
|
|
||||||
//
|
|
||||||
// Created by Hendrik Hogertz on 15.02.26.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
class ShareViewController: UIViewController {
|
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
handleSharedItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSharedItems() {
|
|
||||||
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
|
|
||||||
completeRequest()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in extensionItems {
|
|
||||||
guard let attachments = item.attachments else { continue }
|
|
||||||
for provider in attachments {
|
|
||||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
|
||||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, _ in
|
|
||||||
if let url = item as? URL {
|
|
||||||
self?.openMainApp(with: url.absoluteString)
|
|
||||||
} else {
|
|
||||||
self?.completeRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
|
||||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, _ in
|
|
||||||
if let text = item as? String, let url = URL(string: text), url.scheme?.hasPrefix("http") == true {
|
|
||||||
self?.openMainApp(with: url.absoluteString)
|
|
||||||
} else {
|
|
||||||
self?.completeRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completeRequest()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openMainApp(with urlString: String) {
|
|
||||||
guard let encoded = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
||||||
let appURL = URL(string: "nextcloud-cookbook://import?url=\(encoded)")
|
|
||||||
else {
|
|
||||||
completeRequest()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the responder chain to open the URL
|
|
||||||
var responder: UIResponder? = self
|
|
||||||
while let r = responder {
|
|
||||||
if let application = r as? UIApplication {
|
|
||||||
application.open(appURL, options: [:], completionHandler: nil)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
responder = r.next
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the system a moment to process the URL before dismissing
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
|
||||||
self?.completeRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func completeRequest() {
|
|
||||||
extensionContext?.completeRequest(returningItems: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user