19 Commits

Author SHA1 Message Date
285e91a429 Fix meal plan removal ignored on first attempt after app launch
Guard reconcileFromServer() with a syncStartTime so that entries
modified locally during an active performSync() cycle are never
overwritten by stale server data. This prevents the race condition
where a user removes a meal plan entry while Phase 2 of sync is
still iterating server recipes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 11:54:13 +01:00
1f7f19c74b Fix meal plan not populating on first login and add pull-to-refresh sync
Rewrite MealPlanSyncManager.performSync() (renamed from performInitialSync) to
discover _mealPlanAssignment metadata from all server recipes, not just locally-
known ones. On first sync all recipes are checked; on subsequent syncs only
recipes modified since lastMealPlanSyncDate are fetched (max 5 concurrent).

Trigger meal plan sync from pull-to-refresh on both the recipe and meal plan
tabs, and from the "Refresh all" toolbar button.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 11:40:31 +01:00
c8d9ab7397 Consolidate onboarding into single login page with native styling and full localization
Merge the two-page welcome/login flow into a single page with the app icon,
title, subtitle, and login inputs all on one screen. Replace the custom blue
background and white-on-blue styling with native iOS system colors and
button styles. Add missing translations (de, es, fr) for all onboarding
strings and fix localization by using LocalizedStringKey and String(localized:).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 10:44:51 +01:00
ce2a814e5a Add Share Extension for importing recipes via URL
Adds a Share Extension so users can share URLs from Safari (or any app)
to open the main app with the ImportURLSheet pre-filled. Uses a custom
URL scheme (nextcloud-cookbook://) as the bridge between the extension
and the main app.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 10:17:22 +01:00
151e69ff28 Avoid reusing category thumbnail images in all recipes preview mosaic
Track which recipe IDs are used as category thumbnails and prefer
different images for the all recipes 2x2 mosaic, falling back to
category thumbnails only when not enough other images are available.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 07:52:18 +01:00
5307b502e9 Add category and recipe sorting with multiple modes and order inversion
Categories on the main page can be sorted by Recently Used, Alphabetical,
or Manual (drag-to-reorder). The sort menu appears inline next to the
Categories header. All Recipes is included in the sort order and manual
reorder sheet. Recipes within category and all-recipes lists can be sorted
by Recently Added or Alphabetical, with the sort button in the toolbar.
All non-manual sort modes support order inversion via a Reverse/Default
Order toggle. Date parsing handles both formatted strings and Unix
timestamps, with recipe_id as fallback when dates are unavailable.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 07:46:23 +01:00
fb6b16c1fc Fix category handling, recipe management, and dark mode toggle tint
- Map uncategorized category between * (internal) and empty string
  (API) so selecting Sonstige/Other correctly persists to the server
- Default new recipes to Other (*) category and remove None option
- Add "New Category" option to category picker in recipe edit view
- Include newly created/imported recipes in recently viewed list and
  pre-fetch thumbnails so images display immediately
- Remove deleted recipes from recently viewed list
- Remove broad .tint(.primary) from RecipeTabView that caused white
  toggles in Settings during dark mode
- Rename German "Other" translation from Andere to Sonstige
- Add missing translations for Servings stepper and new category strings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 07:13:01 +01:00
02118e3d7a Add dark mode support with appearance picker and fix hardcoded colors
Add user-facing appearance setting (System/Light/Dark) wired via
preferredColorScheme at the app root. Replace hardcoded .black tints
and foreground styles with .primary so toolbar buttons and text remain
visible in dark mode. Remove profile picture from settings and
SwiftSoup from acknowledgements.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 06:31:14 +01:00
c38d4075be Fix grocery sync deletions not persisting and Reminders race condition
Stop cascading syncs by adding an isReconciling flag so that
reconcileFromServer no longer triggers scheduleSync via addItem/deleteItem.
Make Reminders write-only by removing the diff/sync logic from the
onDataChanged callback. Fetch fresh server state in RecipeView reconcile
instead of using stale local cache. Track pending removal recipe IDs via
DataStore so performInitialSync can push deletions for recipes whose
grocery keys have already been removed from groceryDict.

Fix a race condition in RemindersGroceryStore where EKEventStoreChanged
notifications triggered load() before saveMappings() finished writing to
disk, causing the correct in-memory state to be overwritten with stale
data. Add ignoreNextExternalChange flag to skip self-triggered reloads.

Restyle the add/remove all grocery button to match the Plan recipe button
style using Label, subheadline font, and rounded rectangle background.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 06:04:41 +01:00
8b23652f10 Add meal plan feature with cross-device sync and automatic stale data cleanup
Introduces weekly meal planning with a calendar-based tab view, per-recipe
date assignments synced via Nextcloud Cookbook custom metadata, and 30-day
automatic pruning of old entries on load, save, and sync merge.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 05:23:29 +01:00
5890dbcad4 Add cross-device grocery list sync via Nextcloud Cookbook API
Store a _groceryState JSON field on each recipe to track which
ingredients have been added, completed, or removed. Uses per-item
last-writer-wins conflict resolution with ISO 8601 timestamps.
Debounced push (2s) avoids excessive API calls; pull reconciles
on recipe open and app launch. Includes a settings toggle to
enable/disable sync.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 04:14:02 +01:00
501434bd0e Redesign ingredient list with swipe-to-add grocery action and add-all button
Remove checkbox icons and tap-to-select state from ingredient items. Replace
with bullet points and a small green cart indicator for items already in the
grocery list. Add a full-width "Add All to Grocery List" / "Remove from
Grocery List" button below ingredients. Swipe right on individual ingredients
shows a rounded green/red cart icon to add/remove single items.

Add DE/ES/FR translations for new grocery list button strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 03:44:00 +01:00
1536174586 Redesign recipe creation and edit view with Form-based layout and URL import
Replace the single "+" button with a 2-option menu (Create New Recipe / Import
from URL) across RecipeTabView, RecipeListView, and AllRecipesListView. Add
ImportURLSheet for server-side recipe import with loading and error states.

Completely redesign edit mode to use a native Form layout with inline editing
for all sections (metadata, duration, ingredients, instructions, tools,
nutrition) instead of the previous sheet-based EditableListView approach. Move
delete action from edit toolbar to view mode context menu. Add recipe image
display to the edit form.

Refactor RecipeListView and AllRecipesListView to use closure-based callbacks
instead of Binding<Bool> for the create/import actions. Add preloadedRecipeDetail
support to RecipeView.ViewModel for imported recipes.

Add DE/ES/FR translations for all new UI strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 03:29:20 +01:00
98c82dc537 Add Apple Reminders integration for grocery list with local mapping persistence
Introduce a GroceryListManager facade that delegates to either the existing
in-app GroceryList or a new RemindersGroceryStore backed by EventKit. Users
choose the mode in Settings; when Reminders mode is active the Grocery List
tab is hidden. Recipe-to-reminder grouping uses a local mapping file
(reminder_mappings.data) instead of polluting the reminder's notes field,
with automatic pruning when reminders are deleted externally.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 02:54:52 +01:00
6824dbea6b Fix settings page dismissing immediately by replacing multiple isPresented navigation destinations with value-based NavigationPath
The settings view was being popped immediately after push because multiple
navigationDestination(isPresented:) modifiers on the same view caused SwiftUI
to reset bindings when appState published changes. Replaced with a single
navigationDestination(for: SidebarDestination.self) using an explicit
NavigationStack(path:). Also fixed @ObservedObject -> @StateObject on
SettingsView.ViewModel, added AllRecipesListView/AllRecipesCategoryCardView,
and added translations for new strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 02:20:38 +01:00
c8ddb098d1 Redesign search tab, add category cards, recent recipes, and complete German translations
Overhaul SearchTabView with search history, empty/no-results states, and dynamic
navigation title. Extract CategoryCardView and RecentRecipesSection into standalone
views. Update RecipeTabView, RecipeListView, RecipeCardView, and MainView for the
modernized UI. Add all 12 missing German translations in Localizable.xcstrings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 01:47:16 +01:00
7c824b492e Modernize networking layer and fix category navigation and recipe list bugs
Network layer:
- Replace static CookbookApi protocol with instance-based CookbookApiProtocol
  using async/throws instead of tuple returns
- Refactor ApiRequest to use URLComponents for proper URL encoding, replace
  print statements with OSLog, and return typed NetworkError cases
- Add structured NetworkError variants (httpError, connectionError, etc.)
- Remove global cookbookApi constant in favor of injected dependency on AppState
- Delete unused RecipeEditViewModel, RecipeScraper, and Scraper playground

Data & model fixes:
- Add custom Decodable for RecipeDetail with safe fallbacks for malformed JSON
- Make Category Hashable/Equatable use only `name` so NavigationSplitView
  selection survives category refreshes with updated recipe_count
- Return server-assigned ID from uploadRecipe so new recipes get their ID
  before the post-upload refresh block executes

View updates:
- Refresh both old and new category recipe lists after upload when category
  changes, mapping empty recipeCategory to "*" for uncategorized recipes
- Raise deployment target to iOS 18, adopt new SwiftUI API conventions
- Clean up alerts, onboarding views, and settings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 00:47:28 +01:00
527acd2967 Raise deployment target to iOS 18 and modernize SwiftUI APIs
Adopt modern SwiftUI patterns now that the minimum target is iOS 18:
NavigationStack, .toolbar, .tint, new Tab API with sidebarAdaptable
style, and remove iOS 17 availability checks. Add Liquid Glass effect
support for iOS 26 in TimerView and fix an optional interpolation
warning in AppState.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-14 23:14:57 +01:00
VincentM
512d534edf Update README.md 2024-10-29 13:44:16 +01:00
69 changed files with 10307 additions and 4693 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.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 Normal file
View File

@@ -0,0 +1,117 @@
# 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.

View File

@@ -3,10 +3,11 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 56; objectVersion = 70;
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 */; };
@@ -26,13 +27,10 @@
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; }; A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; }; A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; }; A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A702AE002AE00096CEC /* Alerts.swift */; };
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; }; A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A787B0772B2B1E6400C2DF1B /* DateExtension.swift */; };
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; }; A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */; };
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */; };
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; }; A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E32B02A961007D25F2 /* CookbookApi.swift */; };
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; }; A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */; };
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; }; A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */; };
@@ -63,9 +61,27 @@
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 */; };
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 */
@@ -83,9 +99,32 @@
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>"; };
@@ -109,12 +148,10 @@
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; }; A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; }; A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; };
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; }; A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; }; A787B0772B2B1E6400C2DF1B /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; }; A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationComponents.swift; sourceTree = "<group>"; };
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditViewModel.swift; sourceTree = "<group>"; };
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; }; A79AA8E32B02A961007D25F2 /* CookbookApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApi.swift; sourceTree = "<group>"; };
A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; }; A79AA8E52B02C3CB007D25F2 /* LoggerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggerExtension.swift; sourceTree = "<group>"; };
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; }; A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookApiV1.swift; sourceTree = "<group>"; };
@@ -148,14 +185,45 @@
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>"; };
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 */
E498A79B2F41C35500D7D7A4 /* ShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E498A7A82F41C35500D7D7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = ShareExtension; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
A701717B2AA8E71900064C43 /* Frameworks */ = { A701717B2AA8E71900064C43 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -174,6 +242,13 @@
); );
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 */
@@ -185,6 +260,7 @@
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>";
@@ -195,6 +271,7 @@
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>";
@@ -261,7 +338,6 @@
A70171B72AB2445700064C43 /* Models */ = { A70171B72AB2445700064C43 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
); );
path = Models; path = Models;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -287,6 +363,16 @@
A70171C52AB4C43A00064C43 /* DataModels.swift */, A70171C52AB4C43A00064C43 /* DataModels.swift */,
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */, A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.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>";
@@ -312,7 +398,6 @@
A781E75F2AF8228100452F6F /* RecipeImport */ = { A781E75F2AF8228100452F6F /* RecipeImport */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */,
); );
path = RecipeImport; path = RecipeImport;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -366,6 +451,7 @@
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>";
@@ -387,10 +473,17 @@
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>";
@@ -401,6 +494,7 @@
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */, A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */, A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */,
); );
path = ReusableViews; path = ReusableViews;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -431,14 +525,15 @@
A701717A2AA8E71900064C43 /* Sources */, A701717A2AA8E71900064C43 /* Sources */,
A701717B2AA8E71900064C43 /* Frameworks */, A701717B2AA8E71900064C43 /* Frameworks */,
A701717C2AA8E71900064C43 /* Resources */, A701717C2AA8E71900064C43 /* Resources */,
E498A7A52F41C35500D7D7A4 /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
E498A7A32F41C35500D7D7A4 /* PBXTargetDependency */,
); );
name = "Nextcloud Cookbook iOS Client"; name = "Nextcloud Cookbook iOS Client";
packageProductDependencies = ( packageProductDependencies = (
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
A9CA6CF52B4C63F200F78AB5 /* TPPDF */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
); );
productName = "Nextcloud Cookbook iOS Client"; productName = "Nextcloud Cookbook iOS Client";
@@ -481,6 +576,28 @@
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 */
@@ -488,7 +605,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1430; LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 1500; LastUpgradeCheck = 1500;
TargetAttributes = { TargetAttributes = {
A701717D2AA8E71900064C43 = { A701717D2AA8E71900064C43 = {
@@ -502,6 +619,10 @@
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" */;
@@ -517,7 +638,6 @@
); );
mainGroup = A70171752AA8E71900064C43; mainGroup = A70171752AA8E71900064C43;
packageReferences = ( packageReferences = (
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
); );
productRefGroup = A701717F2AA8E71900064C43 /* Products */; productRefGroup = A701717F2AA8E71900064C43 /* Products */;
@@ -527,6 +647,7 @@
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 */
@@ -557,6 +678,13 @@
); );
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 */
@@ -564,7 +692,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */, C1F0AB022D0B000100000001 /* ImportURLSheet.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 */,
@@ -572,7 +700,7 @@
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */, A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
@@ -586,6 +714,7 @@
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */,
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */, A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */, A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */, A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
@@ -602,6 +731,10 @@
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 */,
@@ -609,11 +742,23 @@
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;
}; };
@@ -634,6 +779,13 @@
); );
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 */
@@ -647,6 +799,11 @@
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 */
@@ -776,13 +933,14 @@
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 = EF2ABA36D9; DEVELOPMENT_TEAM = JGFU6788BP;
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;
@@ -793,12 +951,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 = 16.4; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1; MARKETING_VERSION = 1.10.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -820,13 +978,14 @@
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 = EF2ABA36D9; DEVELOPMENT_TEAM = JGFU6788BP;
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;
@@ -837,12 +996,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 = 16.4; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1; MARKETING_VERSION = 1.10.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -856,17 +1015,16 @@
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 = EF2ABA36D9; DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests"; PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -880,17 +1038,16 @@
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 = EF2ABA36D9; DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests"; PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -904,16 +1061,15 @@
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 = EF2ABA36D9; DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests"; PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -927,16 +1083,15 @@
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 = EF2ABA36D9; DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.4; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0; MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests"; PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -947,6 +1102,79 @@
}; };
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 */
@@ -986,17 +1214,18 @@
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";
@@ -1008,11 +1237,6 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
A74D33BD2AF82AAE00D06555 /* SwiftSoup */ = {
isa = XCSwiftPackageProductDependency;
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */; package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;

View File

@@ -1,14 +1,6 @@
{ {
"originHash" : "314ca0b5cf5f134470eb4e9e12133500ae78d8b9a08f490e0065f2b3ceb4a25a",
"pins" : [ "pins" : [
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "8b6cf29eead8841a1fa7822481cb3af4ddaadba6",
"version" : "2.6.1"
}
},
{ {
"identity" : "tppdf", "identity" : "tppdf",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -19,5 +11,5 @@
} }
} }
], ],
"version" : 2 "version" : 3
} }

View File

@@ -0,0 +1,102 @@
<?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>

View File

@@ -0,0 +1,97 @@
<?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>

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import UIKit import UIKit
@@ -15,16 +16,23 @@ import UIKit
@Published var recipes: [String: [Recipe]] = [:] @Published var recipes: [String: [Recipe]] = [:]
@Published var recipeDetails: [Int: RecipeDetail] = [:] @Published var recipeDetails: [Int: RecipeDetail] = [:]
@Published var timers: [String: RecipeTimer] = [:] @Published var timers: [String: RecipeTimer] = [:]
@Published var categoryImages: [String: UIImage] = [:]
@Published var 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() { init(api: CookbookApiProtocol? = nil) {
print("Created MainViewModel") Logger.network.debug("Created AppState")
self.dataStore = DataStore() self.dataStore = DataStore()
self.api = api ?? CookbookApiFactory.makeClient()
if UserSettings.shared.authString == "" { if UserSettings.shared.authString == "" {
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
@@ -38,54 +46,33 @@ import UIKit
} }
/** // MARK: - Categories
Asynchronously loads and updates the list of categories.
This function attempts to fetch the list of categories from the server. If the server connection is successful, it updates the `categories` property in the `MainViewModel` instance and saves the categories locally. If the server connection fails, it attempts to load the categories from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
*/
func getCategories() async { func getCategories() async {
let (categories, _) = await cookbookApi.getCategories( do {
auth: UserSettings.shared.authString let categories = try await api.getCategories()
) Logger.data.debug("Successfully loaded categories")
if let categories = categories {
print("Successfully loaded categories")
self.categories = categories self.categories = categories
await saveLocal(self.categories, path: "categories.data") await saveLocal(self.categories, path: "categories.data")
} else { } catch {
// If there's no server connection, try loading categories from local storage Logger.data.debug("Loading categories from store ...")
print("Loading categories from store ...")
if let categories: [Category] = await loadLocal(path: "categories.data") { if let categories: [Category] = await loadLocal(path: "categories.data") {
self.categories = categories self.categories = categories
print("Success!") Logger.data.debug("Loaded categories from local store")
} else { } else {
print("Failure!") Logger.data.error("Failed to load categories from local store")
} }
} }
// Initialize the lastUpdates with distantPast dates, so that each recipeDetail is updated on launch for all categories
for category in self.categories { for category in self.categories {
lastUpdates[category.name] = Date.distantPast lastUpdates[category.name] = Date.distantPast
} }
} }
/**
Fetches recipes for a specified category from either the server or local storage.
- Parameters:
- name: The name of the category. Use "*" to fetch recipes without assigned categories.
- needsUpdate: If true, recipes will be loaded from the server directly; otherwise, they will be loaded from local storage first.
This function asynchronously retrieves recipes for the specified category from the server or local storage based on the provided parameters. If `needsUpdate` is true, the function fetches recipes from the server and updates the local storage. If `needsUpdate` is false, it attempts to load recipes from local storage.
- Note: The category name "*" is used for all uncategorized recipes.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
*/
func getCategory(named name: String, fetchMode: FetchMode) async { func getCategory(named name: String, fetchMode: FetchMode) async {
print("getCategory(\(name), fetchMode: \(fetchMode))") Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))")
func getLocal() async -> Bool { func getLocal() async -> Bool {
let categoryString = name == "*" ? "_" : name
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") { if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
self.recipes[name] = recipes self.recipes[name] = recipes
return true return true
@@ -94,22 +81,19 @@ import UIKit
} }
func getServer(store: Bool = false) async -> Bool { func getServer(store: Bool = false) async -> Bool {
let (recipes, _) = await cookbookApi.getCategory( let categoryString = name == "*" ? "_" : name
auth: UserSettings.shared.authString, do {
named: categoryString let recipes = try await api.getCategory(named: categoryString)
)
if let recipes = recipes {
self.recipes[name] = recipes self.recipes[name] = recipes
if store { if store {
await saveLocal(recipes, path: "category_\(categoryString).data") await saveLocal(recipes, path: "category_\(categoryString).data")
} }
//userSettings.lastUpdate = Date()
return true return true
} } catch {
return false return false
} }
}
let categoryString = name == "*" ? "_" : name
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
if await getLocal() { return } if await getLocal() { return }
@@ -124,6 +108,8 @@ import UIKit
} }
} }
// MARK: - Recipe details
func updateAllRecipeDetails() async { func updateAllRecipeDetails() async {
for category in self.categories { for category in self.categories {
await updateRecipeDetails(in: category.name) await updateRecipeDetails(in: category.name)
@@ -137,10 +123,10 @@ import UIKit
for recipe in recipes { for recipe in recipes {
if let dateModified = recipe.dateModified { if let dateModified = recipe.dateModified {
if needsUpdate(category: category, lastModified: dateModified) { if needsUpdate(category: category, lastModified: dateModified) {
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)") Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))")
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
} else { } else {
print("\(recipe.name) is up to date.") Logger.data.debug("\(recipe.name) is up to date.")
} }
} else { } else {
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages) await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
@@ -148,25 +134,11 @@ import UIKit
} }
} }
/**
Asynchronously retrieves all recipes either from the server or the locally cached data.
This function attempts to fetch all recipes from the server using the provided `api`. If the server connection is successful, it returns the fetched recipes. If the server connection fails, it falls back to combining locally cached recipes from different categories.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance, and categories have been previously loaded.
Example usage:
```swift
let recipes = await mainViewModel.getRecipes()
*/
func getRecipes() async -> [Recipe] { func getRecipes() async -> [Recipe] {
let (recipes, error) = await cookbookApi.getRecipes( do {
auth: UserSettings.shared.authString return try await api.getRecipes()
) } catch {
if let recipes = recipes { Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)")
return recipes
} else if let error = error {
print(error)
} }
var allRecipes: [Recipe] = [] var allRecipes: [Recipe] = []
for category in categories { for category in categories {
@@ -174,25 +146,9 @@ import UIKit
allRecipes.append(contentsOf: recipeArray) allRecipes.append(contentsOf: recipeArray)
} }
} }
return allRecipes.sorted(by: { return allRecipes.sorted(by: { $0.name < $1.name })
$0.name < $1.name
})
} }
/**
Asynchronously retrieves a recipe detail either from the server or locally cached data.
This function attempts to fetch a recipe detail with the specified `id` from the server using the provided `api`. If the server connection is successful, it returns the fetched recipe detail. If the server connection fails, it falls back to loading the recipe detail from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe to retrieve.
Example usage:
```swift
let recipeDetail = await mainViewModel.getRecipe(id: 123)
*/
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? { func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
func getLocal() async -> RecipeDetail? { func getLocal() async -> RecipeDetail? {
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
@@ -200,21 +156,18 @@ import UIKit
} }
func getServer() async -> RecipeDetail? { func getServer() async -> RecipeDetail? {
let (recipe, error) = await cookbookApi.getRecipe( do {
auth: UserSettings.shared.authString, let recipe = try await api.getRecipe(id: id)
id: id
)
if let recipe = recipe {
if save { if save {
self.recipeDetails[id] = recipe self.recipeDetails[id] = recipe
await self.saveLocal(recipe, path: "recipe\(id).data") await self.saveLocal(recipe, path: "recipe\(id).data")
} }
return recipe return recipe
} else if let error = error { } catch {
print(error) Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)")
}
return nil return nil
} }
}
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
@@ -231,18 +184,6 @@ import UIKit
return nil return nil
} }
/**
Asynchronously downloads and saves details, thumbnails, and full images for all recipes.
This function iterates through all loaded categories, fetches and updates the recipes from the server, and then downloads and saves details, thumbnails, and full images for each recipe.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
await mainViewModel.downloadAllRecipes()
*/
func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async { func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async {
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) { if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
await saveLocal(recipeDetail, path: "recipe\(id).data") await saveLocal(recipeDetail, path: "recipe\(id).data")
@@ -263,48 +204,24 @@ import UIKit
} }
} }
/// Check if recipeDetail is stored locally, either in cache or on disk
/// - Parameters
/// - recipeId: The id of a recipe.
/// - Returns: True if the recipeDetail is stored, otherwise false
func recipeDetailExists(recipeId: Int) -> Bool { func recipeDetailExists(recipeId: Int) -> Bool {
if (dataStore.recipeDetailExists(recipeId: recipeId)) { return dataStore.recipeDetailExists(recipeId: recipeId)
return true
}
return false
} }
/** // MARK: - Images
Asynchronously retrieves and returns an image for a recipe with the specified ID and size.
This function attempts to fetch an image for a recipe with the specified `id` and `size` from the server using the provided `api`. If the server connection is successful, it returns the fetched image. If the server connection fails or `needsUpdate` is false, it attempts to load the image from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe associated with the image.
- size: The size of the desired image (thumbnail or full).
- needsUpdate: If true, the image will be loaded from the server directly; otherwise, it will be loaded from local storage.
Example usage:
```swift
let thumbnail = await mainViewModel.getImage(id: 123, size: .THUMB, needsUpdate: true)
*/
func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? { func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
func getLocal() async -> UIImage? { func getLocal() async -> UIImage? {
return await imageFromStore(id: id, size: size) return await imageFromStore(id: id, size: size)
} }
func getServer() async -> UIImage? { func getServer() async -> UIImage? {
let (image, _) = await cookbookApi.getImage( do {
auth: UserSettings.shared.authString, return try await api.getImage(id: id, size: size)
id: id, } catch {
size: size
)
if let image = image { return image }
return nil return nil
} }
}
switch fetchMode { switch fetchMode {
case .preferLocal: case .preferLocal:
@@ -356,27 +273,19 @@ import UIKit
return nil return nil
} }
/** // MARK: - Keywords
Asynchronously retrieves and returns a list of keywords (tags).
This function attempts to fetch a list of keywords from the server using the provided `api`. If the server connection is successful, it returns the fetched keywords. If the server connection fails, it attempts to load the keywords from local storage.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
Example usage:
```swift
let keywords = await mainViewModel.getKeywords()
*/
func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] { func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
func getLocal() async -> [RecipeKeyword]? { func getLocal() async -> [RecipeKeyword]? {
return await loadLocal(path: "keywords.data") return await loadLocal(path: "keywords.data")
} }
func getServer() async -> [RecipeKeyword]? { func getServer() async -> [RecipeKeyword]? {
let (tags, _) = await cookbookApi.getTags( do {
auth: UserSettings.shared.authString return try await api.getTags()
) } catch {
return tags return nil
}
} }
switch fetchMode { switch fetchMode {
@@ -400,6 +309,78 @@ 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 = []
@@ -410,30 +391,13 @@ import UIKit
} }
} }
/**
Asynchronously deletes a recipe with the specified ID from the server and local storage.
This function attempts to delete a recipe with the specified `id` from the server using the provided `api`. If the server connection is successful, it proceeds to delete the local copy of the recipe and its details. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- id: The identifier of the recipe to delete.
- categoryName: The name of the category to which the recipe belongs.
Example usage:
```swift
let requestResult = await mainViewModel.deleteRecipe(withId: 123, categoryName: "Desserts")
*/
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? { func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
let (error) = await cookbookApi.deleteRecipe( do {
auth: UserSettings.shared.authString, try await api.deleteRecipe(id: id)
id: id } catch {
)
if let error = error {
return .REQUEST_DROPPED return .REQUEST_DROPPED
} }
let path = "recipe\(id).data" let path = "recipe\(id).data"
dataStore.delete(path: path) dataStore.delete(path: path)
if recipes[categoryName] != nil { if recipes[categoryName] != nil {
@@ -442,89 +406,55 @@ 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 {
let (categories, _) = await cookbookApi.getCategories( do {
auth: UserSettings.shared.authString let categories = try await api.getCategories()
)
if let categories = categories {
self.categories = categories self.categories = categories
await saveLocal(categories, path: "categories.data") await saveLocal(categories, path: "categories.data")
return true return true
} } catch {
return false return false
} }
}
/** func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) {
Asynchronously uploads a recipe to the server. do {
This function attempts to create or update a recipe on the server using the provided `api`. If the server connection is successful, it uploads the provided `recipeDetail`. If the server connection fails, it returns `RequestAlert.REQUEST_DROPPED`.
- Important: This function assumes that the server address, authentication string, and API have been properly configured in the `MainViewModel` instance.
- Parameters:
- recipeDetail: The detailed information of the recipe to upload.
- createNew: If true, creates a new recipe on the server; otherwise, updates an existing one.
Example usage:
```swift
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
*/
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? {
var error: NetworkError? = nil
if createNew { if createNew {
error = await cookbookApi.createRecipe( let id = try await api.createRecipe(recipeDetail)
auth: UserSettings.shared.authString, return (id, nil)
recipe: recipeDetail
)
} else { } else {
error = await cookbookApi.updateRecipe( let id = try await api.updateRecipe(recipeDetail)
auth: UserSettings.shared.authString, return (id, nil)
recipe: recipeDetail
)
} }
if error != nil { } catch {
return .REQUEST_DROPPED return (nil, .REQUEST_DROPPED)
} }
return nil
} }
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) { func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) } do {
let (recipeDetail, error) = await cookbookApi.importRecipe( let recipeDetail = try await api.importRecipe(url: url)
auth: UserSettings.shared.authString, return (recipeDetail, nil)
data: data } catch {
)
if error != nil {
return (nil, .REQUEST_DROPPED) return (nil, .REQUEST_DROPPED)
} }
return (recipeDetail, nil)
} }
} }
// MARK: - Local storage helpers
extension AppState { extension AppState {
func loadLocal<T: Codable>(path: String) async -> T? { func loadLocal<T: Codable>(path: String) async -> T? {
do { do {
return try await dataStore.load(fromPath: path) return try await dataStore.load(fromPath: path)
} catch (let error) { } catch {
print(error) Logger.data.debug("Failed to load local data: \(error.localizedDescription)")
return nil return nil
} }
} }
@@ -542,7 +472,7 @@ extension AppState {
return image return image
} }
} catch { } catch {
print("Could not find image in local storage.") Logger.data.debug("Could not find image in local storage.")
return nil return nil
} }
return nil return nil
@@ -584,24 +514,20 @@ extension AppState {
} }
private func needsUpdate(category: String, lastModified: String) -> Bool { private func needsUpdate(category: String, lastModified: String) -> Bool {
print("=======================")
print("original date string: \(lastModified)")
// Create a DateFormatter
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
// Convert the string to a Date object
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] { if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
if date < lastUpdate { if date < lastUpdate {
print("No update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))") Logger.data.debug("No update needed for \(category)")
return false return false
} else { } else {
print("Update needed. (recipe: \(dateFormatter.string(from: date)), last: \(dateFormatter.string(from: lastUpdate))") Logger.data.debug("Update needed for \(category)")
return true return true
} }
} }
print("String is not a date. Update needed.") Logger.data.debug("Date parse failed, update needed for \(category)")
return true return true
} }
} }

View File

@@ -0,0 +1,22 @@
//
// 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
}

View File

@@ -0,0 +1,55 @@
//
// 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
}

View File

@@ -20,6 +20,14 @@ struct Category: Codable {
extension Category: Identifiable, Hashable { extension Category: Identifiable, Hashable {
var id: String { name } var id: String { name }
static func == (lhs: Category, rhs: Category) -> Bool {
lhs.name == rhs.name
}
func hash(into hasher: inout Hasher) {
hasher.combine(name)
}
} }

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
class DataStore { class DataStore {
@@ -52,7 +53,7 @@ class DataStore {
do { do {
_ = try await task.value _ = try await task.value
} catch { } catch {
print("Could not save data (path: \(path)") Logger.data.error("Could not save data (path: \(path))")
} }
} }
@@ -70,24 +71,18 @@ class DataStore {
} }
func clearAll() -> Bool { func clearAll() -> Bool {
print("Attempting to delete all data ...") Logger.data.debug("Attempting to delete all data ...")
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false } guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }
print("Folder path: ", folderPath)
do { do {
let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath) let filePaths = try fileManager.contentsOfDirectory(atPath: folderPath)
for filePath in filePaths { for filePath in filePaths {
print("File path: ", filePath)
try fileManager.removeItem(atPath: folderPath + filePath) try fileManager.removeItem(atPath: folderPath + filePath)
} }
} catch { } catch {
print("Could not delete documents folder contents: \(error)") Logger.data.error("Could not delete documents folder contents: \(error.localizedDescription)")
return false return false
} }
print("Done.") Logger.data.debug("All data deleted successfully.")
return true return true
} }
} }

View File

@@ -0,0 +1,173 @@
//
// 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()
}
}

View File

@@ -0,0 +1,20 @@
//
// 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
}

View File

@@ -0,0 +1,58 @@
//
// 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)
}
}

View File

@@ -0,0 +1,233 @@
//
// 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)
}
}
}

View File

@@ -0,0 +1,205 @@
//
// 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
}
}

View File

@@ -0,0 +1,83 @@
//
// 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)" }
}

View File

@@ -0,0 +1,168 @@
//
// 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
)
}
}

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
class ObservableRecipeDetail: ObservableObject { class ObservableRecipeDetail: ObservableObject {
@@ -25,6 +26,8 @@ class ObservableRecipeDetail: ObservableObject {
@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
@@ -42,11 +45,13 @@ 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
} }
@@ -62,11 +67,13 @@ 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 recipeCategory = recipeDetail.recipeCategory.isEmpty ? "*" : 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)
} }
@@ -85,11 +92,13 @@ class ObservableRecipeDetail: ObservableObject {
description: self.description, description: self.description,
url: self.url, url: self.url,
recipeYield: self.recipeYield, recipeYield: self.recipeYield,
recipeCategory: self.recipeCategory, recipeCategory: self.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
) )
} }
@@ -180,7 +189,7 @@ class ObservableRecipeDetail: ObservableObject {
} }
return foundMatches return foundMatches
} catch { } catch {
print("Regex error: \(error.localizedDescription)") Logger.data.error("Regex error: \(error.localizedDescription)")
} }
return [] return []
} }

View File

@@ -28,7 +28,7 @@ struct Recipe: Codable {
extension Recipe: Identifiable, Hashable { extension Recipe: Identifiable, Hashable {
var id: String { name } var id: Int { recipe_id }
} }
@@ -50,8 +50,10 @@ 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]) { 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) {
self.name = name self.name = name
self.keywords = keywords self.keywords = keywords
self.dateCreated = dateCreated self.dateCreated = dateCreated
@@ -69,6 +71,8 @@ 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() {
@@ -89,33 +93,80 @@ 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, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
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)
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl) // Server import returns "image"; show/index responses and local storage use "imageUrl"
imageUrl = (try? container.decodeIfPresent(String.self, forKey: .image))
?? (try? container.decodeIfPresent(String.self, forKey: .imageUrl))
id = try container.decode(String.self, forKey: .id) id = try container.decode(String.self, forKey: .id)
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime) prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime) cookTime = try container.decodeIfPresent(String.self, forKey: .cookTime)
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime) totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
description = try container.decode(String.self, forKey: .description) description = (try? container.decode(String.self, forKey: .description)) ?? ""
url = try container.decode(String.self, forKey: .url) url = try? container.decode(String.self, forKey: .url)
recipeYield = try container.decode(Int.self, forKey: .recipeYield) recipeCategory = (try? container.decode(String.self, forKey: .recipeCategory)) ?? ""
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
tool = try container.decode([String].self, forKey: .tool)
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) } // recipeYield: try Int first, then parse leading digits from String
if let yieldInt = try? container.decode(Int.self, forKey: .recipeYield) {
recipeYield = yieldInt
} else if let yieldString = try? container.decode(String.self, forKey: .recipeYield) {
let digits = yieldString.prefix(while: { $0.isNumber })
recipeYield = Int(digits) ?? 0
} else {
recipeYield = 0
}
tool = (try? container.decode([String].self, forKey: .tool)) ?? []
recipeIngredient = (try? container.decode([String].self, forKey: .recipeIngredient)) ?? []
recipeInstructions = (try? container.decode([String].self, forKey: .recipeInstructions)) ?? []
if let nutritionDict = try? container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition) {
nutrition = nutritionDict.mapValues { String(describing: $0.value) }
} else {
nutrition = [:]
}
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(keywords, forKey: .keywords)
try container.encodeIfPresent(dateCreated, forKey: .dateCreated)
try container.encodeIfPresent(dateModified, forKey: .dateModified)
// Encode under "image" the key the server expects for create/update
try container.encodeIfPresent(imageUrl, forKey: .image)
try container.encode(id, forKey: .id)
try container.encodeIfPresent(prepTime, forKey: .prepTime)
try container.encodeIfPresent(cookTime, forKey: .cookTime)
try container.encodeIfPresent(totalTime, forKey: .totalTime)
try container.encode(description, forKey: .description)
try container.encodeIfPresent(url, forKey: .url)
try container.encode(recipeYield, forKey: .recipeYield)
try container.encode(recipeCategory, forKey: .recipeCategory)
try container.encode(tool, forKey: .tool)
try container.encode(recipeIngredient, forKey: .recipeIngredient)
try container.encode(recipeInstructions, forKey: .recipeInstructions)
try container.encode(nutrition, forKey: .nutrition)
try container.encodeIfPresent(groceryState, forKey: .groceryState)
try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment)
} }
} }

View File

@@ -0,0 +1,345 @@
//
// 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)
}
}
}

View File

@@ -121,6 +121,66 @@ 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 ?? ""
@@ -140,6 +200,16 @@ 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 != "" {

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -34,10 +34,10 @@ struct ApiRequest {
// Prepare URL // Prepare URL
let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path let urlString = pathCompletion ? UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + path : path
print("Full path: \(urlString)") guard var components = URLComponents(string: urlString) else { return (nil, .missingUrl) }
//Logger.network.debug("Full path: \(urlString)") // Ensure path percent encoding is applied correctly
guard let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return (nil, .unknownError) } components.percentEncodedPath = components.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? components.path
guard let url = URL(string: urlStringSanitized) else { return (nil, .unknownError) } guard let url = components.url else { return (nil, .missingUrl) }
// Create URL request // Create URL request
var request = URLRequest(url: url) var request = URLRequest(url: url)
@@ -65,39 +65,31 @@ struct ApiRequest {
} }
// Wait for and return data and (decoded) response // Wait for and return data and (decoded) response
var data: Data? = nil
var response: URLResponse? = nil
do { do {
(data, response) = try await URLSession.shared.data(for: request) let (data, response) = try await URLSession.shared.data(for: request)
if let error = decodeURLResponse(response: response as? HTTPURLResponse, data: data) {
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
return (nil, error)
}
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
return (nil, error)
}
if let data = data {
print(data, String(data: data, encoding: .utf8) as Any)
return (data, nil) return (data, nil)
}
return (nil, .unknownError)
} catch { } catch {
let error = decodeURLResponse(response: response as? HTTPURLResponse) Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
Logger.network.debug("\(method.rawValue) \(path) FAILURE: \(error.debugDescription)") return (nil, .connectionError(underlying: error))
return (nil, error)
} }
} }
private func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? { private func decodeURLResponse(response: HTTPURLResponse?, data: Data?) -> NetworkError? {
guard let response = response else { guard let response = response else {
return NetworkError.unknownError return .unknownError(detail: "No HTTP response")
} }
print("Status code: ", response.statusCode) let statusCode = response.statusCode
switch response.statusCode { switch statusCode {
case 200...299: return (nil) case 200...299:
case 300...399: return (NetworkError.redirectionError) return nil
case 400...499: return (NetworkError.clientError) default:
case 500...599: return (NetworkError.serverError) let body = data.flatMap { String(data: $0, encoding: .utf8) }
case 600: return (NetworkError.invalidRequest) return .httpError(statusCode: statusCode, body: body)
default: return (NetworkError.unknownError)
} }
} }
} }

View File

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

View File

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

View File

@@ -7,17 +7,45 @@
import Foundation import Foundation
public enum NetworkError: String, Error { public enum NetworkError: Error, LocalizedError {
case missingUrl = "Missing URL." case missingUrl
case parametersNil = "Parameters are nil." case encodingFailed(detail: String? = nil)
case encodingFailed = "Parameter encoding failed." case decodingFailed(detail: String? = nil)
case decodingFailed = "Data decoding failed." case httpError(statusCode: Int, body: String? = nil)
case redirectionError = "Redirection error" case connectionError(underlying: Error? = nil)
case clientError = "Client error" case invalidRequest
case serverError = "Server error" case unknownError(detail: String? = nil)
case invalidRequest = "Invalid request"
case unknownError = "Unknown error" public var errorDescription: String? {
case dataError = "Invalid data error." switch self {
case .missingUrl:
return "Missing URL."
case .encodingFailed(let detail):
return "Parameter encoding failed." + (detail.map { " \($0)" } ?? "")
case .decodingFailed(let detail):
return "Data decoding failed." + (detail.map { " \($0)" } ?? "")
case .httpError(let statusCode, let body):
return "HTTP error \(statusCode)." + (body.map { " \($0)" } ?? "")
case .connectionError(let underlying):
return "Connection error." + (underlying.map { " \($0.localizedDescription)" } ?? "")
case .invalidRequest:
return "Invalid request."
case .unknownError(let detail):
return "Unknown error." + (detail.map { " \($0)" } ?? "")
}
}
var isClientError: Bool {
if case .httpError(let statusCode, _) = self {
return (400...499).contains(statusCode)
}
return false
}
var isServerError: Bool {
if case .httpError(let statusCode, _) = self {
return (500...599).contains(statusCode)
}
return false
}
} }

View File

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

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
/// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication. /// The `NextcloudApi` class provides functionalities to interact with the Nextcloud API, particularly for user authentication.
@@ -33,10 +34,10 @@ class NextcloudApi {
return (nil, error) return (nil, error)
} }
guard let data = data else { guard let data = data else {
return (nil, NetworkError.dataError) return (nil, NetworkError.encodingFailed())
} }
guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else { guard let loginRequest: LoginV2Request = JSONDecoder.safeDecode(data) else {
return (nil, NetworkError.decodingFailed) return (nil, NetworkError.decodingFailed())
} }
return (loginRequest, nil) return (loginRequest, nil)
} }
@@ -69,10 +70,10 @@ class NextcloudApi {
return (nil, error) return (nil, error)
} }
guard let data = data else { guard let data = data else {
return (nil, NetworkError.dataError) return (nil, NetworkError.encodingFailed())
} }
guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else { guard let loginResponse: LoginV2Response = JSONDecoder.safeDecode(data) else {
return (nil, NetworkError.decodingFailed) return (nil, NetworkError.decodingFailed())
} }
return (loginResponse, nil) return (loginResponse, nil)
} }
@@ -107,11 +108,11 @@ class NextcloudApi {
userId: data?["userId"] as? String ?? "", userId: data?["userId"] as? String ?? "",
userDisplayName: data?["displayName"] as? String ?? "" userDisplayName: data?["displayName"] as? String ?? ""
) )
print(userData) Logger.network.debug("Loaded hover card for user \(userData.userId)")
return (userData, nil) return (userData, nil)
} catch { } catch {
print(error.localizedDescription) Logger.network.error("Failed to decode hover card: \(error.localizedDescription)")
return (nil, NetworkError.decodingFailed) return (nil, NetworkError.decodingFailed())
} }
} }
} }

View File

@@ -13,6 +13,17 @@ import SwiftUI
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 {
@@ -20,15 +31,26 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
if onboarding { if onboarding {
OnboardingView() OnboardingView()
} else { } else {
MainView() MainView(pendingImportURL: $pendingImportURL)
} }
} }
.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
guard !onboarding else { return }
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
}
} }
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,52 +9,82 @@ import SwiftUI
struct MainView: View { struct MainView: View {
@StateObject var appState = AppState() @StateObject var appState = AppState()
@StateObject var groceryList = GroceryList() @StateObject var groceryList = GroceryListManager()
@StateObject var mealPlan = MealPlanManager()
// Tab ViewModels // Tab ViewModels
@StateObject var recipeViewModel = RecipeTabView.ViewModel() @StateObject var recipeViewModel = RecipeTabView.ViewModel()
@StateObject var searchViewModel = SearchTabView.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, groceryList case recipes, search, mealPlan, groceryList
} }
var body: some View { var body: some View {
TabView { TabView(selection: $selectedTab) {
SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) {
RecipeTabView() RecipeTabView()
.environmentObject(recipeViewModel) .environmentObject(recipeViewModel)
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.tabItem { .environmentObject(mealPlan)
Label("Recipes", systemImage: "book.closed.fill")
} }
.tag(Tab.recipes)
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
SearchTabView() SearchTabView()
.environmentObject(searchViewModel) .environmentObject(searchViewModel)
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.tabItem { .environmentObject(mealPlan)
Label("Search", systemImage: "magnifyingglass")
} }
.tag(Tab.search)
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) .environmentObject(groceryList)
.tabItem {
if #available(iOS 17.0, *) {
Label("Grocery List", systemImage: "storefront")
} else {
Label("Grocery List", systemImage: "heart.text.square")
} }
} }
.tag(Tab.groceryList) }
.tabViewStyle(.sidebarAdaptable)
.modifier(TabBarMinimizeModifier())
.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
@@ -67,7 +97,30 @@ 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
}
} else {
recipeViewModel.showImportURLSheet = true
}
}
} }
} }

View File

@@ -6,44 +6,63 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
struct OnboardingView: View { struct OnboardingView: View {
@State var selectedTab: Int = 0 @State var loginMethod: LoginMethod = .v2
// 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 {
TabView(selection: $selectedTab) { ScrollView(showsIndicators: false) {
WelcomeTab().tag(0) VStack(spacing: 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: 120, height: 120) .frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 18))
Text("Thank you for downloading") .padding(.top, 48)
.font(.headline)
Text("Cookbook Client") Text("Cookbook Client")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
Spacer() .padding(.top, 10)
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.") Text("Thanks for downloading! Sign in to your Nextcloud server to get started.")
.padding() .font(.subheadline)
Spacer() .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)
} }
.padding()
.fontDesign(.rounded) .fontDesign(.rounded)
.padding()
.alert(alertMessage, isPresented: $showAlert) {
Button("Ok", role: .cancel) { }
}
}
.background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea())
} }
} }
@@ -78,108 +97,45 @@ 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: String let text: LocalizedStringKey
var body: some View { var body: some View {
Text(text) Text(text)
.foregroundColor(.white) .font(.subheadline)
.font(.headline) .foregroundStyle(.secondary)
.padding(.vertical, 5)
} }
} }
struct BorderedLoginTextField: View { struct BorderedLoginTextField: View {
var example: String var example: LocalizedStringKey
@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( .background(Color(uiColor: .secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 10) .clipShape(RoundedRectangle(cornerRadius: 10))
.stroke(.white, lineWidth: 2)
.foregroundColor(.clear)
)
} }
} }
struct LoginTextField: View { struct LoginTextField: View {
var example: String var example: LocalizedStringKey
@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( .background(Color(uiColor: .secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 10) .clipShape(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
@@ -191,19 +147,19 @@ struct ServerAddressField: View {
} }
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 6) {
LoginLabel(text: "Server address") LoginLabel(text: "Server address")
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 10) {
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) }
.tint(.white) .pickerStyle(.menu)
.font(.headline) .tint(.accentColor)
.onChange(of: serverProtocol) { value in .onChange(of: serverProtocol) { value in
print(value) Logger.view.debug("\(value.rawValue)")
userSettings.serverProtocol = value.rawValue userSettings.serverProtocol = value.rawValue
} }
@@ -211,27 +167,18 @@ struct ServerAddressField: View {
.textFieldStyle(.plain) .textFieldStyle(.plain)
.autocorrectionDisabled() .autocorrectionDisabled()
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.foregroundStyle(.white) .padding(10)
.padding() .background(Color(uiColor: .secondarySystemGroupedBackground))
.background( .clipShape(RoundedRectangle(cornerRadius: 8))
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)
.foregroundColor(.white) .font(.footnote)
.padding(.vertical, 5) .foregroundStyle(.secondary)
} }
.padding() .padding()
.background( .background(Color(uiColor: .secondarySystemGroupedBackground))
RoundedRectangle(cornerRadius: 10) .clipShape(RoundedRectangle(cornerRadius: 12))
.stroke(.white, lineWidth: 2)
.foregroundColor(.clear)
)
} }
} }
} }
@@ -241,6 +188,5 @@ struct ServerAddressField_Preview: PreviewProvider {
ServerAddressField() ServerAddressField()
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.padding() .padding()
.background(Color.nextcloudBlue)
} }
} }

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
@@ -25,25 +26,25 @@ struct TokenLoginView: View {
} }
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 16) {
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) {
@@ -51,19 +52,18 @@ struct TokenLoginView: View {
} }
} }
} label: { } label: {
Text("Submit") Label("Submit", systemImage: "person.badge.key")
.foregroundColor(.white) .font(.subheadline)
.font(.headline) .fontWeight(.medium)
.padding() .frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2) .fill(Color.nextcloudBlue.opacity(0.1))
.foregroundColor(.clear)
) )
} }
.padding() .padding(.top, 4)
Spacer()
}
} }
.onSubmit { .onSubmit {
switch focusedField { switch focusedField {
@@ -72,36 +72,31 @@ struct TokenLoginView: View {
case .username: case .username:
focusedField = .token focusedField = .token
default: default:
print("Attempting to log in ...") Logger.view.debug("Attempting to log in ...")
} }
} }
} }
func loginCheck(nextcloudLogin: Bool) async -> Bool { func loginCheck(nextcloudLogin: Bool) async -> Bool {
if userSettings.serverAddress == "" { if userSettings.serverAddress == "" {
alertMessage = "Please enter a server address!" alertMessage = String(localized: "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 = "Please enter a user name and app token!" alertMessage = String(localized: "Please enter a user name and app token!")
showAlert = true showAlert = true
return false return false
} }
UserSettings.shared.setAuthString() UserSettings.shared.setAuthString()
let (data, error) = await cookbookApi.getCategories(auth: UserSettings.shared.authString) let client = CookbookApiFactory.makeClient()
do {
if let error = error { let _ = try await client.getCategories()
alertMessage = "Login failed. Please check your inputs and internet connection."
showAlert = true
return false
}
guard let data = data else {
alertMessage = "Login failed. Please check your inputs."
showAlert = true
return false
}
return true return true
} catch {
alertMessage = String(localized: "Login failed. Please check your inputs and internet connection.")
showAlert = true
return false
}
} }
} }

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
import WebKit import WebKit
@@ -45,36 +46,35 @@ struct V2LoginView: View {
} }
var body: some View { var body: some View {
ScrollView { VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading) {
ServerAddressField() ServerAddressField()
CollapsibleView {
VStack(alignment: .leading) { CollapsibleView(titleColor: .secondary) {
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.") VStack(alignment: .leading, spacing: 8) {
.padding(.bottom) 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.\nAfter a successful login, return to this application and press 'Validate'.") 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'.")
.padding(.bottom)
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.") 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)
.foregroundStyle(.secondary)
} title: { } title: {
Text("Show help") Text("Show help")
.foregroundColor(.white) .font(.subheadline)
.font(.headline) }
}.padding()
if loginRequest != nil { if loginRequest != nil {
Button("Copy Link") { Button {
UIPasteboard.general.string = loginRequest!.login UIPasteboard.general.string = loginRequest!.login
} label: {
Label("Copy Link", systemImage: "doc.on.doc")
.font(.subheadline)
} }
.font(.headline)
.foregroundStyle(.white)
.padding()
} }
HStack { HStack(spacing: 12) {
Button { Button {
if UserSettings.shared.serverAddress == "" { if UserSettings.shared.serverAddress == "" {
alertMessage = "Please enter a valid server address." alertMessage = String(localized: "Please enter a valid server address.")
showAlert = true showAlert = true
return return
} }
@@ -82,55 +82,52 @@ struct V2LoginView: View {
Task { Task {
let error = await sendLoginV2Request() let error = await sendLoginV2Request()
if let error = error { if let error = error {
alertMessage = "A network error occured (\(error.rawValue))." alertMessage = String(localized: "A network error occurred. Please try again.")
showAlert = true showAlert = true
} }
if let loginRequest = loginRequest { if let _ = loginRequest {
presentBrowser = true presentBrowser = true
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else { } else {
alertMessage = "Unable to reach server. Please check your server address and internet connection." alertMessage = String(localized: "Unable to reach server. Please check your server address and internet connection.")
showAlert = true showAlert = true
} }
} }
loginStage = loginStage.next() loginStage = loginStage.next()
} label: { } label: {
Text("Login") Label("Login", systemImage: "person.badge.key")
.foregroundColor(.white) .font(.subheadline)
.font(.headline) .fontWeight(.medium)
.padding() .frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2) .fill(Color.nextcloudBlue.opacity(0.1))
.foregroundColor(.clear)
) )
}.padding() }
if loginStage == .validate { if loginStage == .validate {
Spacer()
Button { Button {
// fetch login v2 response
Task { Task {
let (response, error) = await fetchLoginV2Response() let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error) checkLogin(response: response, error: error)
} }
} label: { } label: {
Text("Validate") Label("Validate", systemImage: "checkmark.circle.fill")
.foregroundColor(.white) .font(.subheadline)
.font(.headline) .fontWeight(.medium)
.padding() .frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background( .background(
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2) .fill(Color.nextcloudBlue.opacity(0.1))
.foregroundColor(.clear)
) )
} }
.disabled(loginRequest == nil ? true : false) .disabled(loginRequest == nil)
.padding()
}
} }
} }
.padding(.top, 4)
} }
.sheet(isPresented: $presentBrowser, onDismiss: { .sheet(isPresented: $presentBrowser, onDismiss: {
Task { Task {
@@ -151,22 +148,22 @@ struct V2LoginView: View {
} }
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) { func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
guard let loginRequest = loginRequest else { return (nil, .parametersNil) } guard let loginRequest = loginRequest else { return (nil, .invalidRequest) }
return await NextcloudApi.loginV2Response(req: loginRequest) return await NextcloudApi.loginV2Response(req: loginRequest)
} }
func checkLogin(response: LoginV2Response?, error: NetworkError?) { func checkLogin(response: LoginV2Response?, error: NetworkError?) {
if let error = error { if let error = error {
alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))" alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
showAlert = true showAlert = true
return return
} }
guard let response = response else { guard let response = response else {
alertMessage = "Login failed. Please login via the browser and try again." alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
showAlert = true showAlert = true
return return
} }
print("Login successful for user \(response.loginName)!") Logger.network.debug("Login successful for user \(response.loginName)!")
UserSettings.shared.username = response.loginName UserSettings.shared.username = response.loginName
UserSettings.shared.token = response.appPassword UserSettings.shared.token = response.appPassword
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)" let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
@@ -187,12 +184,17 @@ struct WebViewSheet: View {
@State var url: String @State var url: String
var body: some View { var body: some View {
NavigationView { NavigationStack {
WebView(url: URL(string: url)!) WebView(url: URL(string: url)!)
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline) .navigationTitle("Nextcloud Login")
.navigationBarItems(trailing: Button("Done") { .navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
dismiss() dismiss()
}) }
}
}
} }
} }
} }

View File

@@ -0,0 +1,215 @@
//
// 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
}
}

View File

@@ -0,0 +1,166 @@
//
// 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
}
}

View File

@@ -0,0 +1,184 @@
//
// 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
}
}

View File

@@ -0,0 +1,82 @@
//
// CategoryCardView.swift
// Nextcloud Cookbook iOS Client
//
import SwiftUI
struct CategoryCardView: View {
@EnvironmentObject var appState: AppState
let category: Category
var isSelected: Bool = false
@State private var imageLoaded = false
private var displayName: String {
category.name == "*" ? String(localized: "Other") : category.name
}
var body: some View {
ZStack(alignment: .bottomLeading) {
// Background image or gradient fallback
if let image = appState.categoryImages[category.name] {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
.clipped()
.opacity(imageLoaded ? 1 : 0)
.animation(.easeIn(duration: 0.3), value: imageLoaded)
.onAppear { imageLoaded = true }
} else {
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
.overlay(alignment: .center) {
Image(systemName: "book.closed.fill")
.font(.system(size: 36))
.foregroundStyle(.white.opacity(0.5))
}
}
// Bottom scrim with text
VStack(alignment: .leading, spacing: 2) {
Spacer()
LinearGradient(
colors: [.clear, .black.opacity(0.6)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 60)
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 2) {
Text(displayName)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
Text("\(category.recipe_count) recipes")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.85))
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 17))
.overlay(
RoundedRectangle(cornerRadius: 17)
.stroke(isSelected ? Color.nextcloudBlue : .clear, lineWidth: 3)
)
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
.task {
if appState.categoryImages[category.name] == nil {
await appState.getCategoryImage(for: category.name)
}
}
}
}

View File

@@ -0,0 +1,89 @@
//
// 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
}
}
}

View File

@@ -0,0 +1,87 @@
//
// 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
}
}
}

View File

@@ -0,0 +1,110 @@
//
// RecentRecipesSection.swift
// Nextcloud Cookbook iOS Client
//
import SwiftUI
struct RecentRecipesSection: View {
@EnvironmentObject var appState: AppState
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Recently Viewed")
.font(.title2)
.bold()
Spacer()
Button {
appState.clearRecentRecipes()
} label: {
Text("Clear")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 12) {
ForEach(appState.recentRecipes) { recipe in
NavigationLink(value: recipe) {
RecentRecipeCard(recipe: recipe)
.environmentObject(appState)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
}
}
private struct RecentRecipeCard: View {
@EnvironmentObject var appState: AppState
let recipe: Recipe
@State private var thumbnail: UIImage?
private var keywordsText: String? {
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
guard !items.isEmpty else { return nil }
return items.prefix(3).joined(separator: " \u{00B7} ")
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Thumbnail
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 160, height: 120)
.clipped()
} else {
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 160, height: 120)
.overlay {
Image(systemName: "fork.knife")
.font(.title2)
.foregroundStyle(.white.opacity(0.6))
}
}
// Text content
VStack(alignment: .leading, spacing: 2) {
Text(recipe.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
.multilineTextAlignment(.leading)
.foregroundStyle(.primary)
if let keywordsText {
Text(keywordsText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.frame(width: 160)
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
.task {
thumbnail = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
)
}
}
}

View File

@@ -12,52 +12,64 @@ struct RecipeCardView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State var recipe: Recipe @State var recipe: Recipe
@State var recipeThumb: UIImage? @State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
private var keywordsText: String? {
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
guard !items.isEmpty else { return nil }
return items.prefix(3).joined(separator: " \u{00B7} ")
}
var body: some View { var body: some View {
HStack { VStack(alignment: .leading, spacing: 0) {
// Thumbnail
if let recipeThumb = recipeThumb { if let recipeThumb = recipeThumb {
Image(uiImage: recipeThumb) Image(uiImage: recipeThumb)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipped()
} else { } else {
Image(systemName: "square.text.square") LinearGradient(
.resizable() gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
.aspectRatio(contentMode: .fit) startPoint: .topLeading,
.foregroundStyle(Color.white) endPoint: .bottomTrailing
.padding(10) )
.background(Color("ncblue")) .frame(minWidth: 0, maxWidth: .infinity, minHeight: 120, maxHeight: 120)
.frame(width: 80, height: 80) .overlay {
.clipShape(RoundedRectangle(cornerRadius: 17)) Image(systemName: "fork.knife")
.font(.title2)
.foregroundStyle(.white.opacity(0.7))
}
} }
Text(recipe.name)
.font(.headline)
.padding(.leading, 4)
Spacer() // Text content
if let isDownloaded = isDownloaded { VStack(alignment: .leading, spacing: 3) {
VStack { Text(recipe.name)
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down") .font(.subheadline)
.foregroundColor(.secondary) .fontWeight(.medium)
.padding() .lineLimit(2)
Spacer() .multilineTextAlignment(.leading)
if let keywordsText {
Text(keywordsText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
} }
} }
.padding(.horizontal, 8)
.padding(.vertical, 6)
} }
.background(Color.backgroundHighlight) .background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
.task { .task {
recipeThumb = await appState.getImage( recipeThumb = await appState.getImage(
id: recipe.recipe_id, id: recipe.recipe_id,
size: .THUMB, size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
) )
if recipe.storedLocally == nil {
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
}
isDownloaded = recipe.storedLocally
} }
.refreshable { .refreshable {
recipeThumb = await appState.getImage( recipeThumb = await appState.getImage(
@@ -66,6 +78,5 @@ struct RecipeCardView: View {
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
) )
} }
.frame(height: 80)
} }
} }

View File

@@ -12,44 +12,68 @@ import SwiftUI
struct RecipeListView: View { struct RecipeListView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryListManager
@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 = ""
@Binding var showEditView: Bool var onCreateNew: () -> Void
var onImportFromURL: () -> Void
@State var selectedRecipe: Recipe? = 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 {
List(recipesFiltered(), id: \.recipe_id) { recipe in ScrollView {
RecipeCardView(recipe: recipe) VStack(alignment: .leading, spacing: 8) {
.shadow(radius: 2) Text("\(recipes.count) recipes")
.background( .font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal)
LazyVGrid(columns: gridColumns, spacing: 12) {
ForEach(recipes, id: \.recipe_id) { recipe in
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
EmptyView() RecipeCardView(recipe: recipe)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
} }
.listStyle(.plain) }
.padding(.horizontal)
}
.padding(.vertical)
}
} else { } else {
VStack { VStack(spacing: 16) {
Text("There are no recipes in this cookbook!") Image(systemName: "fork.knife")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No recipes in this cookbook")
.font(.headline)
.foregroundStyle(.secondary)
Text("Recipes will appear here once they are added to this category.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
Button { Button {
Task { Task {
await appState.getCategories() await appState.getCategories()
await appState.getCategory(named: categoryName, fetchMode: .preferServer) await appState.getCategory(named: categoryName, fetchMode: .preferServer)
} }
} label: { } label: {
Text("Refresh") Label("Refresh", systemImage: "arrow.clockwise")
.bold() .bold()
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.tint(.primary)
}.padding() }.padding()
} }
} }
@@ -60,12 +84,24 @@ struct RecipeListView: View {
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 {
print("Add new recipe") onCreateNew()
showEditView = true } label: {
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")
} }
@@ -85,12 +121,84 @@ struct RecipeListView: View {
} }
} }
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")
}
}
func recipesFiltered() -> [Recipe] { func recipesFiltered() -> [Recipe] {
guard let recipes = appState.recipes[categoryName] else { return [] } guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes } let filtered: [Recipe]
return recipes.filter { recipe in if searchText.isEmpty {
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term filtered = recipes
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term } else {
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
}
} }

View File

@@ -6,11 +6,14 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
struct RecipeView: View { struct RecipeView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel @StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero @GestureState private var dragOffset = CGSize.zero
@@ -27,97 +30,16 @@ struct RecipeView: View {
} }
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { Group {
VStack(spacing: 0) { if viewModel.editMode {
ParallaxHeader( recipeEditForm
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else { } else {
Rectangle() recipeViewContent
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
} }
} }
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar) .toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline) .navigationTitle(viewModel.editMode ? "Edit Recipe" : (viewModel.showTitle ? viewModel.recipe.name : ""))
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar { .toolbar {
RecipeViewToolBar(viewModel: viewModel) RecipeViewToolBar(viewModel: viewModel)
} }
@@ -126,37 +48,18 @@ struct RecipeView: View {
recipeImage: viewModel.recipeImage, recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet) presentShareSheet: $viewModel.presentShareSheet)
} }
.sheet(isPresented: $viewModel.presentInstructionEditView) { .sheet(isPresented: $viewModel.presentKeywordSheet) {
EditableListView( KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
} }
.sheet(isPresented: $viewModel.presentIngredientEditView) { .sheet(isPresented: $viewModel.presentMealPlanSheet) {
EditableListView( AddToMealPlanSheet(
isPresented: $viewModel.presentIngredientEditView, recipeId: String(viewModel.recipe.recipe_id),
items: $viewModel.observableRecipeDetail.recipeIngredient, recipeName: viewModel.observableRecipeDetail.name,
title: "Ingredients", prepTime: viewModel.recipeDetail.prepTime,
emptyListText: "Start by adding your first ingredient! 🥬", recipeImage: viewModel.recipeImage
titleKey: "Ingredient", )
lineLimit: 0...1, .environmentObject(mealPlan)
axis: .horizontal)
} }
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task { .task {
// Load recipe detail // Load recipe detail
if !viewModel.newRecipe { if !viewModel.newRecipe {
@@ -167,6 +70,9 @@ struct RecipeView: View {
) ?? RecipeDetail.error ) ?? RecipeDetail.error
viewModel.setupView(recipeDetail: recipeDetail) viewModel.setupView(recipeDetail: recipeDetail)
// Track as recently viewed
appState.addToRecentRecipes(viewModel.recipe)
// Show download badge // Show download badge
if viewModel.recipe.storedLocally == nil { if viewModel.recipe.storedLocally == nil {
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id) viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
@@ -180,9 +86,44 @@ struct RecipeView: View {
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
) )
// Reconcile server grocery state with local data
if UserSettings.shared.grocerySyncEnabled {
let serverRecipe = await appState.getRecipe(
id: viewModel.recipe.recipe_id,
fetchMode: .onlyServer
)
groceryList.syncManager?.reconcileFromServer(
serverState: serverRecipe?.groceryState,
recipeId: String(viewModel.recipe.recipe_id),
recipeName: viewModel.recipeDetail.name
)
}
// Reconcile server meal plan state with local data
if UserSettings.shared.mealPlanSyncEnabled {
mealPlan.syncManager?.reconcileFromServer(
serverAssignment: viewModel.recipeDetail.mealPlanAssignment,
recipeId: String(viewModel.recipe.recipe_id),
recipeName: viewModel.recipeDetail.name
)
}
} else { } else {
// Prepare view for a new recipe // Prepare view for a new recipe
if let preloaded = viewModel.preloadedRecipeDetail {
viewModel.setupView(recipeDetail: preloaded)
viewModel.preloadedRecipeDetail = nil
// Load image if the import created a recipe with a valid id
if let recipeId = Int(preloaded.id), recipeId > 0 {
viewModel.recipeImage = await appState.getImage(
id: recipeId,
size: .FULL,
fetchMode: .onlyServer
)
}
} else {
viewModel.setupView(recipeDetail: RecipeDetail()) viewModel.setupView(recipeDetail: RecipeDetail())
}
viewModel.editMode = true viewModel.editMode = true
viewModel.isDownloaded = false viewModel.isDownloaded = false
} }
@@ -227,6 +168,143 @@ struct RecipeView: View {
} }
} }
// MARK: - View Mode
private var recipeViewContent: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
HStack {
Text(viewModel.observableRecipeDetail.name)
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" {
Text(viewModel.observableRecipeDetail.description)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
RecipeDurationSection(viewModel: viewModel)
Button {
viewModel.presentMealPlanSheet = true
} label: {
Label("Plan recipe", systemImage: "calendar.badge.plus")
.font(.subheadline)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.foregroundStyle(Color.nextcloudBlue)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(Color.nextcloudBlue.opacity(0.1))
)
}
.padding(.horizontal)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if !viewModel.observableRecipeDetail.recipeIngredient.isEmpty {
RecipeIngredientSection(viewModel: viewModel)
}
if !viewModel.observableRecipeDetail.recipeInstructions.isEmpty {
RecipeInstructionSection(viewModel: viewModel)
}
if !viewModel.observableRecipeDetail.tool.isEmpty {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
}
// MARK: - Edit Mode Form
private var recipeEditForm: some View {
Form {
if let recipeImage = viewModel.recipeImage {
Section {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: 200)
.clipped()
.listRowInsets(EdgeInsets())
}
}
Section {
TextField("Recipe Name", text: $viewModel.observableRecipeDetail.name)
.font(.headline)
TextField("Description", text: $viewModel.observableRecipeDetail.description, axis: .vertical)
.lineLimit(1...5)
}
RecipeEditMetadataSection(viewModel: viewModel)
.environmentObject(appState)
RecipeEditDurationSection(
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
)
RecipeEditIngredientSection(ingredients: $viewModel.observableRecipeDetail.recipeIngredient)
RecipeEditInstructionSection(instructions: $viewModel.observableRecipeDetail.recipeInstructions)
RecipeEditToolSection(tools: $viewModel.observableRecipeDetail.tool)
RecipeEditNutritionSection(nutrition: $viewModel.observableRecipeDetail.nutrition)
}
}
// MARK: - RecipeView ViewModel // MARK: - RecipeView ViewModel
@@ -237,16 +315,15 @@ struct RecipeView: View {
@Published var editMode: Bool = false @Published var editMode: Bool = false
@Published var showTitle: Bool = false @Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil @Published var isDownloaded: Bool? = nil
@Published var importUrl: String = ""
@Published var presentShareSheet: Bool = false @Published var presentShareSheet: Bool = false
@Published var presentInstructionEditView: Bool = false @Published var presentKeywordSheet: Bool = false
@Published var presentIngredientEditView: Bool = false @Published var presentMealPlanSheet: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe var recipe: Recipe
var sharedURL: URL? = nil var sharedURL: URL? = nil
var newRecipe: Bool = false var newRecipe: Bool = false
var preloadedRecipeDetail: RecipeDetail? = nil
// Alerts // Alerts
@Published var presentAlert = false @Published var presentAlert = false
@@ -286,66 +363,25 @@ struct RecipeView: View {
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
return nil
}
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar // MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent { struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var mealPlan: MealPlanManager
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
var body: some ToolbarContent { var body: some ToolbarContent {
if viewModel.editMode { if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) {
Button("Cancel") { Button("Cancel") {
viewModel.editMode = false viewModel.editMode = false
if viewModel.newRecipe { if viewModel.newRecipe {
dismiss() dismiss()
} }
} }
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
} }
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -371,11 +407,24 @@ struct RecipeViewToolBar: ToolbarContent {
} }
Button { Button {
print("Sharing recipe ...") Logger.view.debug("Sharing recipe ...")
viewModel.presentShareSheet = true viewModel.presentShareSheet = true
} label: { } label: {
Label("Share Recipe", systemImage: "square.and.arrow.up") Label("Share Recipe", systemImage: "square.and.arrow.up")
} }
Divider()
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: { } label: {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
} }
@@ -386,34 +435,73 @@ struct RecipeViewToolBar: ToolbarContent {
func handleUpload() async { func handleUpload() async {
if viewModel.newRecipe { if viewModel.newRecipe {
print("Uploading new recipe.") // Check if the recipe was already created on the server by import
let importedId = Int(viewModel.observableRecipeDetail.id) ?? 0
let alreadyCreatedByImport = importedId > 0
if alreadyCreatedByImport {
Logger.view.debug("Uploading changes to imported recipe (id: \(importedId)).")
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
if let alert {
viewModel.presentAlert(alert)
return
}
} else {
Logger.view.debug("Uploading new recipe.")
if let recipeValidationError = recipeValid() { if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError) viewModel.presentAlert(recipeValidationError)
return return
} }
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) { let (newId, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true)
if let alert {
viewModel.presentAlert(alert) viewModel.presentAlert(alert)
return return
} }
if let newId {
viewModel.observableRecipeDetail.id = String(newId)
}
}
} else { } else {
print("Uploading changed recipe.") Logger.view.debug("Uploading changed recipe.")
guard let _ = Int(viewModel.observableRecipeDetail.id) else { guard let _ = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return return
} }
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) { let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
if let alert {
viewModel.presentAlert(alert) viewModel.presentAlert(alert)
return return
} }
} }
await appState.getCategories() await appState.getCategories()
await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer) let newCategory = viewModel.observableRecipeDetail.recipeCategory.isEmpty ? "*" : viewModel.observableRecipeDetail.recipeCategory
let oldCategory = viewModel.recipeDetail.recipeCategory.isEmpty ? "*" : viewModel.recipeDetail.recipeCategory
await appState.getCategory(named: newCategory, fetchMode: .preferServer)
if oldCategory != newCategory {
await appState.getCategory(named: oldCategory, fetchMode: .preferServer)
}
if let id = Int(viewModel.observableRecipeDetail.id) { if let id = Int(viewModel.observableRecipeDetail.id) {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true) let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
// Fetch the image after upload so it displays in view mode
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
// Pre-fetch thumbnail so it's cached for recents and category lists
let _ = await appState.getImage(id: id, size: .THUMB, fetchMode: .onlyServer)
// Update recipe reference so the view tracks the server-assigned id
viewModel.recipe = Recipe(
name: viewModel.observableRecipeDetail.name,
keywords: viewModel.observableRecipeDetail.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
imageUrl: viewModel.observableRecipeDetail.imageUrl,
imagePlaceholderUrl: "",
recipe_id: id
)
appState.addToRecentRecipes(viewModel.recipe)
} }
viewModel.newRecipe = false
viewModel.editMode = false viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS) viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
} }
@@ -430,6 +518,7 @@ struct RecipeViewToolBar: ToolbarContent {
} }
await appState.getCategories() await appState.getCategories()
await appState.getCategory(named: category, fetchMode: .preferServer) await appState.getCategory(named: category, fetchMode: .preferServer)
mealPlan.removeAllAssignments(forRecipeId: String(id))
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS) viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss() dismiss()
} }

View File

@@ -21,23 +21,70 @@ struct RecipeDurationSection: View {
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking")) DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time")) DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
} }
if viewModel.editMode {
Button {
presentPopover.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
.padding(.top, 5)
}
} }
.padding() .padding()
.popover(isPresented: $presentPopover) { }
EditableDurationView( }
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime, // MARK: - Recipe Edit Duration Section (Form-based)
totalTime: viewModel.observableRecipeDetail.totalTime
) 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()
}
} }
} }
} }

View File

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

View File

@@ -11,32 +11,12 @@ import SwiftUI
// MARK: - RecipeView Ingredients Section // MARK: - RecipeView Ingredients Section
struct RecipeIngredientSection: View { struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryListManager
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Button {
withAnimation {
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: {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
} else {
Image(systemName: "heart.text.square")
}
}.disabled(viewModel.editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients")) SecondaryLabel(text: LocalizedStringKey("Ingredients"))
Spacer() Spacer()
@@ -53,14 +33,9 @@ struct RecipeIngredientSection: View {
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix], ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
servings: $viewModel.observableRecipeDetail.ingredientMultiplier, servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield), recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
recipeId: viewModel.observableRecipeDetail.id recipeId: viewModel.observableRecipeDetail.id,
) {
groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix],
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name recipeName: viewModel.observableRecipeDetail.name
) )
}
.padding(4) .padding(4)
} }
@@ -73,57 +48,117 @@ struct RecipeIngredientSection: View {
}.padding(.top) }.padding(.top)
} }
if viewModel.editMode {
Button { Button {
viewModel.presentIngredientEditView.toggle() withAnimation {
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: {
Text("Edit") Label(
} groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "Remove all from Grocery List" : "Add All to Grocery List",
.buttonStyle(.borderedProminent) systemImage: groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "cart.badge.minus" : "cart.badge.plus"
)
.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: viewModel.observableRecipeDetail.ingredientMultiplier)
} }
} }
// MARK: - Recipe Edit Ingredient Section (Form-based)
struct RecipeEditIngredientSection: View {
@Binding var ingredients: [String]
var body: some View {
Section {
ForEach(ingredients.indices, id: \.self) { index in
HStack {
TextField("Ingredient", text: $ingredients[index])
Image(systemName: "line.3.horizontal")
.foregroundStyle(.tertiary)
}
}
.onDelete { indexSet in
ingredients.remove(atOffsets: indexSet)
}
.onMove { from, to in
ingredients.move(fromOffsets: from, toOffset: to)
}
Button {
ingredients.append("")
} label: {
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: GroceryList @EnvironmentObject var groceryList: GroceryListManager
@Binding var ingredient: String @Binding var ingredient: String
@Binding var servings: Double @Binding var servings: Double
@State var recipeYield: Double @State var recipeYield: Double
@State var recipeId: String @State var recipeId: String
let addToGroceryListAction: () -> Void 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
} }
// Drag animation // Swipe state
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
@State private var animationStartOffset: CGFloat = 0 @State private var animationStartOffset: CGFloat = 0
let maxDragDistance = 50.0 private let maxDragDistance: CGFloat = 80
private let swipeThreshold: CGFloat = 0.4
private var isInGroceryList: Bool {
groceryList.containsItem(at: recipeId, item: ingredient)
}
var body: some View { var body: some View {
HStack(alignment: .top) { ZStack(alignment: .leading) {
if groceryList.containsItem(at: recipeId, item: ingredient) { // Swipe background
if #available(iOS 17.0, *) { if dragOffset > 0 {
Image(systemName: "storefront") Image(systemName: isInGroceryList ? "cart.badge.minus" : "cart.badge.plus")
.foregroundStyle(Color.green) .font(.caption)
} else { .bold()
Image(systemName: "heart.text.square") .foregroundStyle(.white)
.foregroundStyle(Color.green) .frame(width: dragOffset, alignment: .center)
.frame(maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isInGroceryList ? Color.red : Color.green)
)
} }
} else if isSelected { // Ingredient row
Image(systemName: "checkmark.circle") HStack(alignment: .center) {
} else { Text("")
Image(systemName: "circle") .foregroundStyle(.secondary)
}
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)
@@ -136,10 +171,18 @@ 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))
.offset(x: dragOffset)
}
.clipped()
.onChange(of: servings) { newServings in .onChange(of: servings) { newServings in
if recipeYield == 0 { if recipeYield == 0 {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings) modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
@@ -147,34 +190,29 @@ fileprivate struct IngredientListItem: View {
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield) modifiedIngredient = ObservableRecipeDetail.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(dragAmount - maxDragDistance, 0.7)) - animationStartOffset let offset = min(dragAmount, maxDragDistance + pow(max(0, dragAmount - maxDragDistance), 0.7)) - animationStartOffset
self.dragOffset = max(0, offset) self.dragOffset = max(0, offset)
} }
.onEnded { gesture in .onEnded { _ in
withAnimation { withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold if dragOffset > maxDragDistance * swipeThreshold {
if groceryList.containsItem(at: recipeId, item: ingredient) { if isInGroceryList {
groceryList.deleteItem(ingredient, fromRecipe: recipeId) groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else { } else {
addToGroceryListAction() groceryList.addItem(
ingredient,
toRecipe: recipeId,
recipeName: recipeName
)
} }
} }
// Animate back to original position
self.dragOffset = 0 self.dragOffset = 0
self.animationStartOffset = 0 self.animationStartOffset = 0
} }

View File

@@ -22,14 +22,6 @@ struct RecipeInstructionSection: View {
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1) RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
} }
if viewModel.editMode {
Button {
viewModel.presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
} }
.padding() .padding()
@@ -37,6 +29,44 @@ struct RecipeInstructionSection: View {
} }
// 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

View File

@@ -33,9 +33,8 @@ struct RecipeMetadataSection: View {
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) { Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Text("").tag("")
ForEach(categories, id: \.self) { item in ForEach(categories, id: \.self) { item in
Text(item) Text(item == "*" ? String(localized: "Other") : item).tag(item)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
@@ -87,6 +86,83 @@ struct RecipeMetadataSection: View {
} }
} }
// 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)
}
}
}
}
}
fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item { fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@Binding var value: Item @Binding var value: Item

View File

@@ -63,10 +63,42 @@ struct RecipeNutritionSection: View {
func nutritionEmpty() -> Bool { func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases { for nutrition in Nutrition.allCases {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] { if let _ = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
return false return false
} }
} }
return true return true
} }
} }
// 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 }
)
}
}

View File

@@ -21,17 +21,40 @@ struct RecipeToolSection: View {
} }
RecipeListSection(list: $viewModel.observableRecipeDetail.tool) RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
if viewModel.editMode {
Button {
viewModel.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")
}
}
}

View File

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

View File

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

View File

@@ -5,47 +5,28 @@
// 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 {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryListManager: GroceryListManager
@ObservedObject var userSettings = UserSettings.shared @ObservedObject var userSettings = UserSettings.shared
@ObservedObject var viewModel = ViewModel() @StateObject 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 == "*" ? "Other" : category.name).tag(category) Text(category.name == "*" ? String(localized: "Other") : category.name).tag(category)
} }
} }
} header: { } header: {
@@ -54,6 +35,66 @@ 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")
@@ -135,7 +176,7 @@ struct SettingsView: View {
Section { Section {
Button("Log out") { Button("Log out") {
print("Log out.") Logger.view.debug("Log out.")
viewModel.alertType = .LOG_OUT viewModel.alertType = .LOG_OUT
viewModel.showAlert = true viewModel.showAlert = true
@@ -143,7 +184,7 @@ struct SettingsView: View {
.tint(.red) .tint(.red)
Button("Delete local data") { Button("Delete local data") {
print("Clear cache.") Logger.view.debug("Clear cache.")
viewModel.alertType = .DELETE_CACHE viewModel.alertType = .DELETE_CACHE
viewModel.showAlert = true viewModel.showAlert = true
} }
@@ -156,13 +197,6 @@ 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)
@@ -185,7 +219,16 @@ struct SettingsView: View {
Text(viewModel.alertType.getMessage()) Text(viewModel.alertType.getMessage())
} }
.task { .task {
await viewModel.getUserData() remindersPermission = groceryListManager.remindersPermissionStatus
if remindersPermission == .fullAccess {
reminderLists = groceryListManager.availableReminderLists()
}
}
.onChange(of: userSettings.groceryListMode) { _, _ in
remindersPermission = groceryListManager.remindersPermissionStatus
if remindersPermission == .fullAccess {
reminderLists = groceryListManager.availableReminderLists()
}
} }
} }
@@ -205,9 +248,6 @@ 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
@@ -232,16 +272,6 @@ extension SettingsView {
} }
} }
} }
func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar()
let (userData, _) = await NextcloudApi.getHoverCard()
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
}
} }
} }

View File

@@ -6,11 +6,12 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
struct GroceryListTabView: View { struct GroceryListTabView: View {
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryListManager
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -54,7 +55,7 @@ struct GroceryListTabView: View {
groceryList.deleteAll() groceryList.deleteAll()
} label: { } label: {
Text("Delete") Text("Delete")
.foregroundStyle(Color.nextcloudBlue) .foregroundStyle(.primary)
} }
} }
} }
@@ -150,7 +151,7 @@ class GroceryRecipeItem: Identifiable, Codable {
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) { func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("Adding item of recipe \(String(describing: recipeName))") Logger.view.debug("Adding item of recipe \(String(describing: recipeName))")
DispatchQueue.main.async { DispatchQueue.main.async {
if self.groceryDict[recipeId] != nil { if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName)) self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
@@ -174,7 +175,7 @@ class GroceryRecipeItem: Identifiable, Codable {
} }
func deleteItem(_ itemName: String, fromRecipe recipeId: String) { func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
print("Deleting item \(itemName)") Logger.view.debug("Deleting item \(itemName)")
guard let recipe = groceryDict[recipeId] else { return } guard let recipe = groceryDict[recipeId] else { return }
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return } guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
groceryDict[recipeId]?.items.remove(at: itemIndex) groceryDict[recipeId]?.items.remove(at: itemIndex)
@@ -186,20 +187,20 @@ class GroceryRecipeItem: Identifiable, Codable {
} }
func deleteGroceryRecipe(_ recipeId: String) { func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)") Logger.view.debug("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId) groceryDict.removeValue(forKey: recipeId)
save() save()
objectWillChange.send() objectWillChange.send()
} }
func deleteAll() { func deleteAll() {
print("Deleting all grocery items") Logger.view.debug("Deleting all grocery items")
groceryDict = [:] groceryDict = [:]
save() save()
} }
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) { func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)") Logger.view.debug("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle() groceryItem.isChecked.toggle()
save() save()
} }
@@ -229,7 +230,7 @@ class GroceryRecipeItem: Identifiable, Codable {
) else { return } ) else { return }
self.groceryDict = groceryDict self.groceryDict = groceryDict
} catch { } catch {
print("Unable to load grocery list") Logger.view.error("Unable to load grocery list")
} }
} }
} }

View File

@@ -0,0 +1,415 @@
//
// 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 }
}

View File

@@ -6,76 +6,270 @@
// //
import Foundation import Foundation
import OSLog
import SwiftUI import SwiftUI
struct RecipeTabView: View { struct RecipeTabView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryListManager
@EnvironmentObject var mealPlan: MealPlanManager
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @EnvironmentObject var viewModel: RecipeTabView.ViewModel
@ObservedObject private var userSettings = UserSettings.shared
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@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 {
List(selection: $viewModel.selectedCategory) { NavigationStack(path: $viewModel.sidebarPath) {
// Categories ScrollView {
ForEach(appState.categories) { category in VStack(alignment: .leading, spacing: 20) {
NavigationLink(value: category) { // Recently Viewed
HStack(alignment: .center) { if !appState.recentRecipes.isEmpty {
if viewModel.selectedCategory != nil && RecentRecipesSection()
category.name == viewModel.selectedCategory!.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
} }
// Categories header with sort button
if hasCategories {
HStack {
Text("Categories")
.font(.title2)
.bold()
Spacer() Spacer()
Text("\(category.recipe_count)") categorySortMenu
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
} }
}.padding(7) .padding(.horizontal)
}
// 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)
} }
} }
} }
.navigationTitle("Cookbooks") .padding(.horizontal)
}
}
.padding(.vertical)
}
.navigationTitle("Recipes")
.toolbar { .toolbar {
RecipeTabViewToolBar() RecipeTabViewToolBar()
} }
.navigationDestination(isPresented: $viewModel.presentSettingsView) { .navigationDestination(for: SidebarDestination.self) { destination in
switch destination {
case .settings:
SettingsView() SettingsView()
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList)
case .newRecipe:
RecipeView(viewModel: {
let vm = RecipeView.ViewModel()
if let imported = viewModel.importedRecipeDetail {
vm.preloadedRecipeDetail = imported
} }
.navigationDestination(isPresented: $viewModel.presentEditView) { return vm
RecipeView(viewModel: RecipeView.ViewModel()) }())
.environmentObject(appState) .environmentObject(appState)
.environmentObject(groceryList) .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 { NavigationStack {
if let category = viewModel.selectedCategory { if viewModel.showAllRecipesInDetail {
AllRecipesListView(
onCreateNew: { viewModel.navigateToNewRecipe() },
onImportFromURL: { viewModel.showImportURLSheet = true }
)
} else if let category = viewModel.selectedCategory {
RecipeListView( RecipeListView(
categoryName: category.name, categoryName: category.name,
showEditView: $viewModel.presentEditView onCreateNew: { viewModel.navigateToNewRecipe() },
onImportFromURL: { viewModel.showImportURLSheet = true }
) )
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes .id(category.id)
}
} }
} }
.tint(.nextcloudBlue) }
.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() let connection = await appState.checkServerConnection()
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -88,18 +282,61 @@ struct RecipeTabView: View {
viewModel.serverConnection = connection viewModel.serverConnection = connection
} }
await appState.getCategories() await appState.getCategories()
for category in appState.categories {
await appState.getCategory(named: category.name, fetchMode: .preferServer)
await appState.getCategoryImage(for: category.name)
}
if UserSettings.shared.mealPlanSyncEnabled {
await mealPlan.syncManager?.performSync()
}
} }
} }
enum SidebarDestination: Hashable {
case settings
case newRecipe
case category(Category)
case allRecipes
}
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var presentEditView: Bool = false @Published var sidebarPath = NavigationPath()
@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 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)
}
} }
} }
@@ -108,6 +345,7 @@ struct RecipeTabView: View {
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
@@ -122,6 +360,9 @@ 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: {
@@ -130,7 +371,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
} }
Button { Button {
viewModel.presentSettingsView = true viewModel.navigateToSettings()
} label: { } label: {
Text("Settings") Text("Settings")
Image(systemName: "gearshape") Image(systemName: "gearshape")
@@ -143,7 +384,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Server connection indicator // Server connection indicator
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
print("Check server connection") Logger.view.debug("Check server connection")
viewModel.presentConnectionPopover = true viewModel.presentConnectionPopover = true
} label: { } label: {
if viewModel.presentLoadingIndicator { if viewModel.presentLoadingIndicator {
@@ -169,9 +410,18 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
// Create new recipes // Create new recipes
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Menu {
Button { Button {
print("Add new recipe") Logger.view.debug("Add new recipe")
viewModel.presentEditView = true viewModel.navigateToNewRecipe()
} 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")
} }

View File

@@ -11,13 +11,92 @@ 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 {
VStack { List {
List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in let results = viewModel.recipesFiltered()
RecipeCardView(recipe: recipe)
.shadow(radius: 2) if viewModel.searchText.isEmpty {
// Icon + explainer
Section {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("Search for recipes")
.font(.headline)
.foregroundStyle(.secondary)
Text("Enter a recipe name or keyword to get started.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.listRowSeparator(.hidden)
}
// Search history
if !viewModel.searchHistory.isEmpty {
Section {
ForEach(viewModel.searchHistory, id: \.self) { term in
Button {
viewModel.searchText = term
} label: {
HStack(spacing: 10) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
.font(.subheadline)
Text(term)
.font(.subheadline)
.foregroundStyle(.primary)
Spacer()
}
}
}
.onDelete { offsets in
viewModel.removeHistory(at: offsets)
}
} header: {
HStack {
Text("Recent searches")
Spacer()
Button {
viewModel.clearHistory()
} label: {
Text("Clear")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
} else if results.isEmpty {
// No results
Section {
VStack(spacing: 12) {
Image(systemName: "magnifyingglass.circle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No results found")
.font(.headline)
.foregroundStyle(.secondary)
Text("Try a different search term.")
.font(.subheadline)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
.listRowSeparator(.hidden)
}
} else {
// Results
Section {
ForEach(results, id: \.recipe_id) { recipe in
SearchRecipeRow(recipe: recipe)
.background( .background(
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
EmptyView() EmptyView()
@@ -25,17 +104,22 @@ struct SearchTabView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.opacity(0) .opacity(0)
) )
.frame(height: 85) .listRowInsets(EdgeInsets(top: 6, leading: 15, bottom: 6, trailing: 15))
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear) .listRowSeparatorTint(.clear)
} }
}
}
}
.listStyle(.plain) .listStyle(.plain)
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.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 {
@@ -51,24 +135,114 @@ struct SearchTabView: View {
@Published var allRecipes: [Recipe] = [] @Published var allRecipes: [Recipe] = []
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var searchMode: SearchMode = .name @Published var searchMode: SearchMode = .name
@Published var searchHistory: [String] = []
private static let historyKey = "searchHistory"
private static let maxHistory = 15
init() {
self.searchHistory = UserDefaults.standard.stringArray(forKey: Self.historyKey) ?? []
}
enum SearchMode: String, CaseIterable { enum SearchMode: String, CaseIterable {
case name = "Name & Keywords", ingredient = "Ingredients" case name = "Name & Keywords", ingredient = "Ingredients"
} }
func recipesFiltered() -> [Recipe] { func recipesFiltered() -> [Recipe] {
guard searchText != "" else { return [] }
if searchMode == .name { if searchMode == .name {
guard searchText != "" else { return allRecipes }
return allRecipes.filter { recipe in return allRecipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term recipe.name.lowercased().contains(searchText.lowercased()) ||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term (recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
} }
} else if searchMode == .ingredient { } else if searchMode == .ingredient {
// TODO: Fuzzy ingredient search // TODO: Fuzzy ingredient search
} }
return [] return []
} }
func saveToHistory(_ term: String) {
let trimmed = term.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return }
searchHistory.removeAll { $0.lowercased() == trimmed.lowercased() }
searchHistory.insert(trimmed, at: 0)
if searchHistory.count > Self.maxHistory {
searchHistory = Array(searchHistory.prefix(Self.maxHistory))
}
UserDefaults.standard.set(searchHistory, forKey: Self.historyKey)
}
func removeHistory(at offsets: IndexSet) {
searchHistory.remove(atOffsets: offsets)
UserDefaults.standard.set(searchHistory, forKey: Self.historyKey)
}
func clearHistory() {
searchHistory = []
UserDefaults.standard.removeObject(forKey: Self.historyKey)
}
}
}
// MARK: - Horizontal row card for search results
private struct SearchRecipeRow: View {
@EnvironmentObject var appState: AppState
@State var recipe: Recipe
@State private var recipeThumb: UIImage?
private var keywordsText: String? {
guard let keywords = recipe.keywords, !keywords.isEmpty else { return nil }
let items = keywords.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
guard !items.isEmpty else { return nil }
return items.prefix(3).joined(separator: " \u{00B7} ")
}
var body: some View {
HStack(spacing: 10) {
if let recipeThumb {
Image(uiImage: recipeThumb)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 70, height: 70)
.clipShape(RoundedRectangle(cornerRadius: 12))
} else {
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: 70, height: 70)
.overlay {
Image(systemName: "fork.knife")
.foregroundStyle(.white.opacity(0.7))
}
.clipShape(RoundedRectangle(cornerRadius: 12))
}
VStack(alignment: .leading, spacing: 3) {
Text(recipe.name)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(2)
if let keywordsText {
Text(keywordsText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
}
.task {
recipeThumb = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
)
}
} }
} }

View File

@@ -1,5 +1,17 @@
<?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>

View File

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

21
ShareExtension/Info.plist Normal file
View File

@@ -0,0 +1,21 @@
<?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>

View File

@@ -0,0 +1,79 @@
//
// 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)
}
}