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>
This commit is contained in:
110
CLAUDE.md
Normal file
110
CLAUDE.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Nextcloud Cookbook iOS Client — a native iOS/iPadOS/macOS (Mac Catalyst) client for the [Nextcloud Cookbook](https://github.com/nextcloud/cookbook) server application. Built entirely in Swift and SwiftUI. Licensed under GPLv3.
|
||||||
|
|
||||||
|
**This is not a standalone app.** It requires a Nextcloud server with the Cookbook app installed.
|
||||||
|
|
||||||
|
## Build & Run
|
||||||
|
|
||||||
|
This is an Xcode project (no workspace, no CocoaPods). Open `Nextcloud Cookbook iOS Client.xcodeproj` in Xcode.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build from command line
|
||||||
|
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
||||||
|
-scheme "Nextcloud Cookbook iOS Client" \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
build
|
||||||
|
|
||||||
|
# Run tests (note: tests are currently boilerplate stubs with no real coverage)
|
||||||
|
xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
||||||
|
-scheme "Nextcloud Cookbook iOS Client" \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
||||||
|
test
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Deployment target**: iOS 16.4
|
||||||
|
- **Swift version**: 5.0
|
||||||
|
- **Targets**: iPhone and iPad, Mac Catalyst enabled
|
||||||
|
|
||||||
|
## Dependencies (SPM)
|
||||||
|
|
||||||
|
Only two third-party packages, managed via Swift Package Manager integrated in the Xcode project:
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| [SwiftSoup](https://github.com/scinfu/SwiftSoup.git) (2.6.1) | HTML parsing for client-side recipe scraping |
|
||||||
|
| [TPPDF](https://github.com/techprimate/TPPDF.git) (2.4.1) | PDF generation for recipe export |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Central State Pattern
|
||||||
|
|
||||||
|
The app uses a **centralized `AppState` ObservableObject** (`AppState.swift`) as the primary ViewModel, injected via `.environmentObject()` into the SwiftUI view hierarchy. `AppState` owns:
|
||||||
|
- All data (categories, recipes, recipe details, images, timers)
|
||||||
|
- All CRUD operations against the Cookbook API
|
||||||
|
- A `FetchMode` enum (`preferLocal`, `preferServer`, `onlyLocal`, `onlyServer`) that governs the data-fetching strategy (server-first vs local-first)
|
||||||
|
- Local persistence via `DataStore`
|
||||||
|
|
||||||
|
Additional ViewModels exist as nested classes within their views (`RecipeTabView.ViewModel`, `SearchTabView.ViewModel`) or as standalone classes (`RecipeEditViewModel`, `GroceryList`).
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
SwiftUI Views
|
||||||
|
├── @EnvironmentObject appState: AppState
|
||||||
|
├── @EnvironmentObject groceryList: GroceryList
|
||||||
|
└── Per-view @StateObject ViewModels
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
AppState
|
||||||
|
├── cookbookApi (CookbookApiV1 — static methods) → ApiRequest → URLSession
|
||||||
|
├── DataStore (file-based JSON persistence in Documents directory)
|
||||||
|
└── UserSettings.shared (UserDefaults singleton)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Layer
|
||||||
|
|
||||||
|
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
|
||||||
|
- The global `cookbookApi` constant (`CookbookApi.swift:14`) resolves the API version at launch.
|
||||||
|
- `ApiRequest` is a generic HTTP request builder using `URLSession.shared.data(for:)` with HTTP Basic Auth.
|
||||||
|
- `NextcloudApi` handles Nextcloud-specific auth (Login Flow v2 and token-based login).
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
- **`DataStore`**: File-based persistence using `JSONEncoder`/`JSONDecoder` writing to the app's Documents directory. No Core Data or SQLite.
|
||||||
|
- **`UserSettings`**: Singleton wrapping `UserDefaults` for all user preferences and credentials.
|
||||||
|
- **Image caching**: Two-tier — in-memory dictionary in `AppState` + on-disk base64-encoded PNG files via `DataStore`.
|
||||||
|
|
||||||
|
### Key Source Directories
|
||||||
|
|
||||||
|
```
|
||||||
|
Nextcloud Cookbook iOS Client/
|
||||||
|
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings
|
||||||
|
├── Models/ # RecipeEditViewModel
|
||||||
|
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
||||||
|
├── Views/
|
||||||
|
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab)
|
||||||
|
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
|
||||||
|
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
|
||||||
|
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
|
||||||
|
│ └── ReusableViews/
|
||||||
|
├── Extensions/ # Color, Date, JSONCoder, Logger extensions
|
||||||
|
├── Util/ # Alerts, DurationComponents (ISO 8601 PT parser), JsonAny, NumberFormatter
|
||||||
|
├── RecipeExport/ # PDF, text, JSON export via RecipeExporter
|
||||||
|
└── RecipeImport/ # HTML scraping via SwiftSoup (schema.org ld+json Recipe data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Four languages supported via `Localizable.xcstrings`: English, German, Spanish, French. Spanish and French are mostly machine-translated.
|
||||||
|
|
||||||
|
## Notable Design Decisions
|
||||||
|
|
||||||
|
- The `cookbookApi` global is resolved once at launch based on `UserSettings.shared.cookbookApiVersion` and uses static protocol methods, which makes dependency injection and unit testing difficult.
|
||||||
|
- Server credentials (username, token, authString) are stored in `UserDefaults` via `UserSettings`, not in Keychain.
|
||||||
|
- No `.gitignore` file exists in the repository.
|
||||||
|
- No CI/CD, no linting tools, and no meaningful test coverage.
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
||||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
||||||
|
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; };
|
||||||
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
|
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
|
||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
||||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
||||||
|
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = "<group>"; };
|
||||||
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
|
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||||
@@ -401,6 +403,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>";
|
||||||
@@ -586,6 +589,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 */,
|
||||||
@@ -793,7 +797,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
@@ -837,7 +841,7 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
@@ -863,7 +867,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||||
@@ -887,7 +891,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
||||||
@@ -910,7 +914,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||||
@@ -933,7 +937,7 @@
|
|||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = EF2ABA36D9;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ 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)")
|
print("\(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.")
|
print("\(recipe.name) is up to date.")
|
||||||
|
|||||||
@@ -15,41 +15,35 @@ struct MainView: View {
|
|||||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||||
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
||||||
|
|
||||||
|
@State private var selectedTab: Tab = .recipes
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
case recipes, search, groceryList
|
case recipes, search, groceryList
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView {
|
TabView(selection: $selectedTab) {
|
||||||
|
SwiftUI.Tab("Recipes", systemImage: "book.closed.fill", value: .recipes) {
|
||||||
RecipeTabView()
|
RecipeTabView()
|
||||||
.environmentObject(recipeViewModel)
|
.environmentObject(recipeViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.tabItem {
|
|
||||||
Label("Recipes", systemImage: "book.closed.fill")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.recipes)
|
|
||||||
|
|
||||||
|
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
||||||
SearchTabView()
|
SearchTabView()
|
||||||
.environmentObject(searchViewModel)
|
.environmentObject(searchViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.tabItem {
|
|
||||||
Label("Search", systemImage: "magnifyingglass")
|
|
||||||
}
|
}
|
||||||
.tag(Tab.search)
|
|
||||||
|
|
||||||
|
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) {
|
||||||
GroceryListTabView()
|
GroceryListTabView()
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
.tabItem {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Label("Grocery List", systemImage: "storefront")
|
|
||||||
} else {
|
|
||||||
Label("Grocery List", systemImage: "heart.text.square")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tag(Tab.groceryList)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
}
|
.modifier(TabBarMinimizeModifier())
|
||||||
.task {
|
.task {
|
||||||
recipeViewModel.presentLoadingIndicator = true
|
recipeViewModel.presentLoadingIndicator = true
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ struct BorderedLoginTextField: View {
|
|||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.accentColor(color)
|
.tint(color)
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
@@ -170,7 +170,7 @@ struct LoginTextField: View {
|
|||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.accentColor(color)
|
.tint(color)
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
|||||||
@@ -187,12 +187,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()
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct EditableListView: View {
|
|||||||
@State var axis: Axis = .vertical
|
@State var axis: Axis = .vertical
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
List {
|
List {
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
@@ -101,12 +101,15 @@ struct EditableListView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitle(title, displayMode: .inline)
|
.navigationTitle(title)
|
||||||
.navigationBarItems(
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
trailing: Button(action: { isPresented = false }) {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(action: { isPresented = false }) {
|
||||||
Text("Done")
|
Text("Done")
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
.environment(\.editMode, .constant(.active))
|
.environment(\.editMode, .constant(.active))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ struct RecipeIngredientSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Image(systemName: "storefront")
|
Image(systemName: "storefront")
|
||||||
} else {
|
|
||||||
Image(systemName: "heart.text.square")
|
|
||||||
}
|
|
||||||
}.disabled(viewModel.editMode)
|
}.disabled(viewModel.editMode)
|
||||||
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
@@ -111,14 +107,8 @@ fileprivate struct IngredientListItem: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Image(systemName: "storefront")
|
Image(systemName: "storefront")
|
||||||
.foregroundStyle(Color.green)
|
.foregroundStyle(Color.green)
|
||||||
} else {
|
|
||||||
Image(systemName: "heart.text.square")
|
|
||||||
.foregroundStyle(Color.green)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if isSelected {
|
} else if isSelected {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -55,10 +55,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user