Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift
Hendrik Hogertz 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

506 lines
19 KiB
Swift

//
// RecipeDetailView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import OSLog
import SwiftUI
struct RecipeView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let image = viewModel.recipeImage {
return image.size.height < 350 ? image.size.height : 350
}
return 200
}
private enum CoordinateSpaces {
case scrollView
}
var body: some View {
Group {
if viewModel.editMode {
recipeEditForm
} else {
recipeViewContent
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
.navigationTitle(viewModel.editMode ? "Edit Recipe" : (viewModel.showTitle ? viewModel.recipe.name : ""))
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
}
.task {
// Load recipe detail
if !viewModel.newRecipe {
// For existing recipes, load the recipeDetail and image
let recipeDetail = await appState.getRecipe(
id: viewModel.recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? RecipeDetail.error
viewModel.setupView(recipeDetail: recipeDetail)
// Track as recently viewed
appState.addToRecentRecipes(viewModel.recipe)
// Show download badge
if viewModel.recipe.storedLocally == nil {
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
}
viewModel.isDownloaded = viewModel.recipe.storedLocally
// Load recipe image
viewModel.recipeImage = await appState.getImage(
id: viewModel.recipe.recipe_id,
size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
)
// Reconcile server grocery state with local data
if UserSettings.shared.grocerySyncEnabled {
groceryList.syncManager?.reconcileFromServer(
serverState: viewModel.recipeDetail.groceryState,
recipeId: String(viewModel.recipe.recipe_id),
recipeName: viewModel.recipeDetail.name
)
}
} else {
// 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.editMode = true
viewModel.isDownloaded = false
}
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await viewModel.alertAction()
}
}
} else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await viewModel.alertAction()
}
}
}
}
} message: {
Text(viewModel.alertType.localizedDescription)
}
.onAppear {
if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
.onChange(of: viewModel.editMode) { newValue in
if newValue && appState.allKeywords.isEmpty {
Task {
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
a.recipe_count > b.recipe_count
})
}
}
}
}
// 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)
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
class ViewModel: ObservableObject {
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail()
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
@Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false
@Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil
@Published var presentShareSheet: Bool = false
@Published var presentKeywordSheet: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil
var newRecipe: Bool = false
var preloadedRecipeDetail: RecipeDetail? = nil
// Alerts
@Published var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(recipe: Recipe) {
self.recipe = recipe
}
init() {
self.newRecipe = true
self.recipe = Recipe(
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
imagePlaceholderUrl: "",
recipe_id: 0)
}
// View setup
func setupView(recipeDetail: RecipeDetail) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
alertType = type
alertAction = action
presentAlert = true
}
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
await handleUpload()
}
} label: {
if viewModel.newRecipe {
Text("Upload Recipe")
} else {
Text("Upload Changes")
}
}
}
} else {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
viewModel.editMode = true
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
Logger.view.debug("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
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: {
Image(systemName: "ellipsis.circle")
}
}
}
}
func handleUpload() async {
if viewModel.newRecipe {
// 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() {
viewModel.presentAlert(recipeValidationError)
return
}
let (newId, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true)
if let alert {
viewModel.presentAlert(alert)
return
}
if let newId {
viewModel.observableRecipeDetail.id = String(newId)
}
}
} else {
Logger.view.debug("Uploading changed recipe.")
guard let _ = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return
}
let (_, alert) = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false)
if let alert {
viewModel.presentAlert(alert)
return
}
}
await appState.getCategories()
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) {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
// Fetch the image after upload so it displays in view mode
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
// Update recipe reference so the view tracks the server-assigned id
viewModel.recipe = Recipe(
name: viewModel.observableRecipeDetail.name,
keywords: viewModel.observableRecipeDetail.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
imageUrl: viewModel.observableRecipeDetail.imageUrl,
imagePlaceholderUrl: "",
recipe_id: id
)
}
viewModel.newRecipe = false
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.observableRecipeDetail.recipeCategory
guard let id = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return
}
if let alert = await appState.deleteRecipe(withId: id, categoryName: viewModel.observableRecipeDetail.recipeCategory) {
viewModel.presentAlert(alert)
return
}
await appState.getCategories()
await appState.getCategory(named: category, fetchMode: .preferServer)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in appState.recipes.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.observableRecipeDetail.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}