Nextcloud Login refactoring

This commit is contained in:
VincentMeilinger
2025-05-31 11:12:14 +02:00
parent 5acf3b9c4f
commit 48b31a7997
29 changed files with 1277 additions and 720 deletions

View File

@@ -8,89 +8,58 @@
import SwiftUI
import SwiftData
struct MainView: View {
//@State var cookbookState: CookbookState = CookbookState()
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe] = []
struct MainView: View {
// Tab ViewModels
enum Tab {
case recipes, settings, groceryList
}
var body: some View {
VStack {
List {
ForEach(recipes) { recipe in
Text(recipe.name)
TabView {
RecipeTabView()
.tabItem {
Label("Recipes", systemImage: "book.closed.fill")
}
}
Button("New") {
let recipe = Recipe(id: UUID().uuidString, name: "Neues Rezept", keywords: [], prepTime: "", cookTime: "", totalTime: "", recipeDescription: "", yield: 0, category: "", tools: [], ingredients: [], instructions: [], nutrition: [:], ingredientMultiplier: 0)
modelContext.insert(recipe)
}
.tag(Tab.recipes)
GroceryListTabView()
.tabItem {
if #available(iOS 17.0, *) {
Label("Grocery List", systemImage: "storefront")
} else {
Label("Grocery List", systemImage: "heart.text.square")
}
}
.tag(Tab.groceryList)
SettingsTabView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(Tab.settings)
}
/*NavigationSplitView {
VStack {
List(selection: $cookbookState.selectedCategory) {
ForEach(cookbookState.categories) { category in
Text(category.name)
.tag(category)
}
}
.listStyle(.plain)
.onAppear {
Task {
await cookbookState.loadCategories()
.task {
/*
recipeViewModel.presentLoadingIndicator = true
await appState.getCategories()
await appState.updateAllRecipeDetails()
// Open detail view for default category
if UserSettings.shared.defaultCategory != "" {
if let cat = appState.categories.first(where: { c in
if c.name == UserSettings.shared.defaultCategory {
return true
}
return false
}) {
recipeViewModel.selectedCategory = cat
}
}
} content: {
if let selectedCategory = cookbookState.selectedCategory {
List(selection: $cookbookState.selectedRecipeStub) {
ForEach(cookbookState.recipeStubs[selectedCategory.name] ?? [], id: \.id) { recipeStub in
Text(recipeStub.title)
.tag(recipeStub)
}
}
.onAppear {
Task {
await cookbookState.loadRecipeStubs(category: selectedCategory.name)
}
}
} else {
Text("Please select a category.")
.foregroundColor(.secondary)
}
} detail: {
if let selectedRecipe = cookbookState.selectedRecipe {
if let recipe = cookbookState.recipes[selectedRecipe.id] {
RecipeView(recipe: recipe)
} else {
ProgressView()
.onAppear {
Task {
await cookbookState.loadRecipe(id: selectedRecipe.id)
}
}
}
} else {
Text("Please select a recipe.")
.foregroundColor(.secondary)
}
await groceryList.load()
recipeViewModel.presentLoadingIndicator = false
*/
}
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action: {
cookbookState.showGroceries = true
}) {
Label("Grocery List", systemImage: "cart")
}
}
ToolbarItem(placement: .topBarLeading) {
Button(action: {
cookbookState.showSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
}
}*/
}
}

View File

@@ -8,7 +8,14 @@
import Foundation
import SwiftUI
import WebKit
/*
import AuthenticationServices
protocol LoginStage {
func next() -> Self
func previous() -> Self
}
enum V2LoginStage: LoginStage {
case login, validate
@@ -30,12 +37,27 @@ enum V2LoginStage: LoginStage {
struct V2LoginView: View {
@Binding var showAlert: Bool
@Binding var alertMessage: String
@Environment(\.dismiss) var dismiss
@State var showAlert: Bool = false
@State var alertMessage: String = ""
@State var loginStage: V2LoginStage = .login
@State var loginRequest: LoginV2Request? = nil
@State var presentBrowser = false
@State var serverAddress: String = ""
@State var serverProtocol: ServerProtocol = .https
@State var loginPressed: Bool = false
@State var isLoading: Bool = false
// Task reference for polling, to cancel if needed
@State private var pollTask: Task<Void, Never>? = nil
enum ServerProtocol: String {
case https="https://", http="http://"
static let all = [https, http]
}
// TextField handling
enum Field {
@@ -45,114 +67,205 @@ struct V2LoginView: View {
}
var body: some View {
ScrollView {
VStack(alignment: .leading) {
ServerAddressField()
CollapsibleView {
VStack(alignment: .leading) {
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.")
.padding(.bottom)
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'.")
.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.")
}
} title: {
Text("Show help")
.foregroundColor(.white)
.font(.headline)
}.padding()
if loginRequest != nil {
Button("Copy Link") {
UIPasteboard.general.string = loginRequest!.login
}
.font(.headline)
.foregroundStyle(.white)
.padding()
VStack {
HStack {
Button("Cancel") {
dismiss()
}
Spacer()
HStack {
Button {
if UserSettings.shared.serverAddress == "" {
alertMessage = "Please enter a valid server address."
showAlert = true
return
}
Task {
let error = await sendLoginV2Request()
if let error = error {
alertMessage = "A network error occured (\(error.localizedDescription))."
showAlert = true
}
if let loginRequest = loginRequest {
presentBrowser = true
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Text("Login")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}.padding()
if isLoading {
ProgressView()
}
}.padding()
Form {
Section {
HStack {
Text("Server address:")
TextField("example.com", text: $serverAddress)
.multilineTextAlignment(.trailing)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
if loginStage == .validate {
Spacer()
Button {
// fetch login v2 response
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
Picker("Server Protocol:", selection: $serverProtocol) {
ForEach(ServerProtocol.all, id: \.self) {
Text($0.rawValue)
}
.disabled(loginRequest == nil ? true : false)
.padding()
}
HStack {
Button("Login") {
initiateLoginV2()
}
Spacer()
Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines))
.foregroundStyle(Color.secondary)
}
} header: {
Text("Nextcloud Login")
} footer: {
Text(
"""
The 'Login' button will open a web browser. Please follow the login instructions provided there.
After a successful login, return to this application and press 'Validate'.
If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.
"""
)
}.disabled(loginPressed)
if let loginRequest = loginRequest {
Section {
Text(loginRequest.login)
.font(.caption)
.foregroundStyle(.secondary)
Button("Copy Link") {
UIPasteboard.general.string = loginRequest.login
}
} footer: {
Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.")
}
}
}
}
.sheet(isPresented: $presentBrowser, onDismiss: {
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
.sheet(isPresented: $presentBrowser) {
if let loginReq = loginRequest {
LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
switch result {
case .success(let url):
print("Login completed with URL: \(url)")
dismiss()
case .failure(let error):
print("Login failed: \(error.localizedDescription)")
self.alertMessage = error.localizedDescription
self.isLoading = false
self.loginPressed = false
self.showAlert = true
}
}
} else {
Text("Error: Login URL not available.")
}
}) {
if let loginRequest = loginRequest {
WebViewSheet(url: loginRequest.login)
}
.alert("Error", isPresented: $showAlert) {
Button("Copy Error") {
print("Error copied: \(alertMessage)")
UIPasteboard.general.string = alertMessage
isLoading = false
loginPressed = false
}
Button("Dismiss") {
print("Error dismissed.")
isLoading = false
loginPressed = false
}
} message: {
Text(alertMessage)
}
}
func sendLoginV2Request() async -> NetworkError? {
let (req, error) = await NextcloudApi.loginV2Request()
self.loginRequest = req
return error
func initiateLoginV2() {
isLoading = true
loginPressed = true
Task {
let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)
let (req, error) = await NextcloudApi.loginV2Request(baseAddress)
if let error = error {
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return
}
guard let req = req else {
self.alertMessage = "Failed to get login URL from server."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return
}
self.loginRequest = req
// Present the browser session
presentBrowser = true
// Start polling in a separate task
startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token)
}
}
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
guard let loginRequest = loginRequest else { return (nil, .parametersNil) }
return await NextcloudApi.loginV2Response(req: loginRequest)
func startPolling(pollURL: String, pollToken: String) {
// Cancel any existing poll task first
pollTask?.cancel()
var pollingFailed = true
pollTask = Task {
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
for _ in 0..<maxRetries {
if Task.isCancelled {
print("Task cancelled.")
break
}
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
if Task.isCancelled {
print("Task cancelled.")
break
}
if let response = response {
// Success
print("Task succeeded.")
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
pollingFailed = false
await MainActor.run {
self.checkLogin(response: response, error: nil)
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
self.isLoading = false
self.loginPressed = false
}
return
} else if let error = error {
if case .clientError(statusCode: 404) = error {
// Continue polling
print("Polling unsuccessful, continuing.")
} else {
// A more serious error occurred during polling
print("Polling error: \(error.localizedDescription)")
await MainActor.run {
self.alertMessage = "Polling error: \(error.localizedDescription)"
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
return
}
}
isLoading = true
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
isLoading = false
}
// If polling finishes without success
if !Task.isCancelled && pollingFailed {
await MainActor.run {
self.alertMessage = "Login timed out. Please try again."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
}
}
}
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
@@ -180,33 +293,72 @@ struct V2LoginView: View {
// Login WebView logic
struct LoginBrowserView: UIViewControllerRepresentable {
let authURL: URL
let callbackURLScheme: String
var completion: (Result<URL, Error>) -> Void
struct WebViewSheet: View {
@Environment(\.dismiss) var dismiss
@State var url: String
func makeUIViewController(context: Context) -> UIViewController {
UIViewController()
}
var body: some View {
NavigationView {
WebView(url: URL(string: url)!)
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline)
.navigationBarItems(trailing: Button("Done") {
dismiss()
})
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if !context.coordinator.sessionStarted {
context.coordinator.sessionStarted = true
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
context.coordinator.sessionStarted = false // Reset for potential retry
if let callbackURL = callbackURL {
completion(.success(callbackURL))
} else if let error = error {
completion(.failure(error))
} else {
// Handle unexpected nil URL and error
completion(.failure(LoginError.unknownError))
}
}
session.presentationContextProvider = context.coordinator
session.prefersEphemeralWebBrowserSession = false
session.start()
}
}
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
var parent: LoginBrowserView
var sessionStarted: Bool = false // Prevent starting multiple sessions
init(_ parent: LoginBrowserView) {
self.parent = parent
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
return windowScene.windows.first!
}
return ASPresentationAnchor()
}
}
enum LoginError: Error, LocalizedError {
case unknownError
var errorDescription: String? {
switch self {
case .unknownError: return "An unknown error occurred during login."
}
}
}
}
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
let request = URLRequest(url: url)
uiView.load(request)
}
#Preview {
V2LoginView()
}
*/

View File

@@ -8,10 +8,10 @@
import Foundation
import SwiftUI
/*
struct RecipeCardView: View {
@EnvironmentObject var appState: AppState
@State var recipe: CookbookApiRecipeV1
//@EnvironmentObject var appState: AppState
@State var recipe: Recipe
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
@@ -50,6 +50,7 @@ struct RecipeCardView: View {
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17))
.task {
/*
recipeThumb = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
@@ -59,18 +60,20 @@ struct RecipeCardView: View {
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
}
isDownloaded = recipe.storedLocally
*/
}
.refreshable {
/*
recipeThumb = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
)
)*/
}
.frame(height: 80)
}
}
*/
/*
struct RecipeCardView: View {
@State var state: AccountState

View File

@@ -7,6 +7,53 @@
import Foundation
import SwiftUI
import SwiftData
struct RecipeListView: View {
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe]
@Binding var selectedRecipe: Recipe?
@Binding var selectedCategory: String?
init(selectedCategory: Binding<String?>, selectedRecipe: Binding<Recipe?>) {
var predicate: Predicate<Recipe>? = nil
if let category = selectedCategory.wrappedValue, category != "*" {
predicate = #Predicate<Recipe> {
$0.category == category
}
}
_recipes = Query(filter: predicate, sort: \.name)
_selectedRecipe = selectedRecipe
_selectedCategory = selectedCategory
}
var body: some View {
List(selection: $selectedRecipe) {
ForEach(recipes) { recipe in
RecipeCardView(recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink(value: recipe) {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
}
.listStyle(.plain)
.navigationTitle("Recipes")
.toolbar {
}
}
}
/*

View File

@@ -8,15 +8,15 @@
import Foundation
import SwiftUI
/*
struct RecipeView: View {
@EnvironmentObject var appState: AppState
@Bindable var recipe: Recipe
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let image = viewModel.recipeImage {
if let recipeImage = recipe.image, let image = recipeImage.image {
return image.size.height < 350 ? image.size.height : 350
}
return 200
@@ -33,8 +33,8 @@ struct RecipeView: View {
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
if let recipeImage = recipe.image, let image = recipeImage.image {
Image(uiImage: image)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
@@ -54,15 +54,12 @@ struct RecipeView: View {
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
//RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
//RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
EditableText(text: $recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
@@ -74,36 +71,37 @@ struct RecipeView: View {
}
}.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
if recipe.recipeDescription != "" || viewModel.editMode {
EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
if(!recipe.ingredients.isEmpty || viewModel.editMode) {
RecipeIngredientSection(recipe: recipe, editMode: $viewModel.editMode, presentIngredientEditView: $viewModel.presentIngredientEditView)
}
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
if(!recipe.instructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView)
}
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
if(!recipe.tools.isEmpty || viewModel.editMode) {
RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView)
}
RecipeNutritionSection(viewModel: viewModel)
RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
//RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(recipe: recipe)
}
}
}
@@ -115,21 +113,21 @@ struct RecipeView: View {
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
/*ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
presentShareSheet: $viewModel.presentShareSheet)*/
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions,
items: $recipe.instructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
@@ -139,7 +137,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.observableRecipeDetail.recipeIngredient,
items: $recipe.ingredients,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
@@ -149,7 +147,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool,
items: $recipe.tools,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
@@ -158,6 +156,7 @@ struct RecipeView: View {
}
.task {
/*
// Load recipe detail
if !viewModel.newRecipe {
// For existing recipes, load the recipeDetail and image
@@ -185,7 +184,7 @@ struct RecipeView: View {
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
viewModel.editMode = true
viewModel.isDownloaded = false
}
}*/
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
@@ -217,13 +216,14 @@ struct RecipeView: View {
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
})
}
}
}*/
}
}
@@ -231,9 +231,8 @@ struct RecipeView: View {
// MARK: - RecipeView ViewModel
class ViewModel: ObservableObject {
@Published var observableRecipeDetail: Recipe = Recipe()
@Published var recipeDetail: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1.error
@Published var recipeImage: UIImage? = nil
@Published var recipe: Recipe
@Published var editMode: Bool = false
@Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil
@@ -244,7 +243,6 @@ struct RecipeView: View {
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: CookbookApiRecipeV1
var sharedURL: URL? = nil
var newRecipe: Bool = false
@@ -254,26 +252,13 @@ struct RecipeView: View {
var alertAction: () async -> () = { }
// Initializers
init(recipe: CookbookApiRecipeV1) {
init(recipe: Recipe) {
self.recipe = recipe
}
init() {
self.newRecipe = true
self.recipe = CookbookApiRecipeV1(
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
imagePlaceholderUrl: "",
recipe_id: 0)
}
// View setup
func setupView(recipeDetail: CookbookApiRecipeDetailV1) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = Recipe(recipeDetail)
self.recipe = Recipe()
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
@@ -285,7 +270,7 @@ struct RecipeView: View {
}
/*
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
@@ -309,13 +294,12 @@ extension RecipeView {
return nil
}
}
*/
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel
@@ -385,6 +369,7 @@ struct RecipeViewToolBar: ToolbarContent {
}
func handleUpload() async {
/*
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
@@ -416,9 +401,11 @@ struct RecipeViewToolBar: ToolbarContent {
}
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)
@@ -432,11 +419,13 @@ struct RecipeViewToolBar: ToolbarContent {
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: "") == "" {
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
@@ -454,12 +443,11 @@ struct RecipeViewToolBar: ToolbarContent {
}
}
}
*/
return nil
}
}
*/

View File

@@ -9,19 +9,20 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Duration Section
/*
struct RecipeDurationSection: View {
@State var viewModel: RecipeView.ViewModel
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@State var presentPopover: Bool = false
var body: some View {
VStack(alignment: .leading) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
DurationView(time: viewModel.recipe.prepTime, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.recipe.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.recipe.totalTime, title: LocalizedStringKey("Total time"))
DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation"))
DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking"))
DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time"))
}
if viewModel.editMode {
if editMode {
Button {
presentPopover.toggle()
} label: {
@@ -34,9 +35,9 @@ struct RecipeDurationSection: View {
.padding()
.popover(isPresented: $presentPopover) {
EditableDurationView(
prepTime: viewModel.recipe.prepTime,
cookTime: viewModel.recipe.cookTime,
totalTime: viewModel.recipe.totalTime
prepTime: recipe.prepTimeDurationComponent,
cookTime: recipe.cookTimeDurationComponent,
totalTime: recipe.totalTimeDurationComponent
)
}
}
@@ -143,4 +144,4 @@ fileprivate struct TimePickerView: View {
}
}
*/

View File

@@ -7,18 +7,24 @@
import Foundation
import SwiftUI
import SwiftData
// MARK: - RecipeView Ingredients Section
/*
struct RecipeIngredientSection: View {
@Environment(CookbookState.self) var cookbookState
@State var viewModel: RecipeView.ViewModel
@Environment(\.modelContext) var modelContext
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentIngredientEditView: Bool
@State var recipeGroceries: RecipeGroceries? = nil
var body: some View {
VStack(alignment: .leading) {
HStack {
Button {
withAnimation {
/*
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
} else {
@@ -28,6 +34,7 @@ struct RecipeIngredientSection: View {
recipeName: viewModel.recipe.name
)
}
*/
}
} label: {
if #available(iOS 17.0, *) {
@@ -35,7 +42,7 @@ struct RecipeIngredientSection: View {
} else {
Image(systemName: "heart.text.square")
}
}.disabled(viewModel.editMode)
}.disabled(editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
@@ -45,26 +52,30 @@ struct RecipeIngredientSection: View {
.foregroundStyle(.secondary)
.bold()
ServingPickerView(selectedServingSize: $viewModel.recipe.ingredientMultiplier)
ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier)
}
ForEach(0..<viewModel.recipe.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(
ingredient: $viewModel.recipe.recipeIngredient[ix],
servings: $viewModel.recipe.ingredientMultiplier,
recipeYield: Double(viewModel.recipe.recipeYield),
recipeId: viewModel.recipe.id
ForEach(0..<recipe.ingredients.count, id: \.self) { ix in
/*IngredientListItem(
ingredient: $recipe.recipeIngredient[ix],
servings: $recipe.ingredientMultiplier,
recipeYield: Double(recipe.recipeYield),
recipeId: recipe.id
) {
/*
cookbookState.groceryList.addItem(
viewModel.recipe.recipeIngredient[ix],
toRecipe: viewModel.recipe.id,
recipeName: viewModel.recipe.name
)
recipe.recipeIngredient[ix],
toRecipe: recipe.id,
recipeName: recipe.name
)*/
}
.padding(4)
.padding(4)*/
Text(recipe.ingredients[ix])
}
if viewModel.recipe.ingredientMultiplier != Double(viewModel.recipe.recipeYield) {
if recipe.ingredientMultiplier != Double(recipe.yield) {
HStack() {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.secondary)
@@ -73,9 +84,9 @@ struct RecipeIngredientSection: View {
}.padding(.top)
}
if viewModel.editMode {
if editMode {
Button {
viewModel.presentIngredientEditView.toggle()
presentIngredientEditView.toggle()
} label: {
Text("Edit")
}
@@ -83,19 +94,80 @@ struct RecipeIngredientSection: View {
}
}
.padding()
.animation(.easeInOut, value: viewModel.recipe.ingredientMultiplier)
.animation(.easeInOut, value: recipe.ingredientMultiplier)
}
func toggleAllGroceryItems(_ itemNames: [String], inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete category if it exists
modelContext.delete(existingCategory)
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
newCategory.items.append(newItem)
}
try modelContext.save()
}
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
func toggleGroceryItem(_ itemName: String, inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete item if it exists
if existingCategory.items.contains(where: { $0.name == itemName }) {
existingCategory.items.removeAll { $0.name == itemName }
// Delete category if empty
if existingCategory.items.isEmpty {
modelContext.delete(existingCategory)
}
} else {
existingCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
} else {
// Add the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add the item to the new category
newCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
}
// MARK: - RecipeIngredientSection List Item
/*
fileprivate struct IngredientListItem: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.modelContext) var modelContext
@Bindable var recipeGroceries: RecipeGroceries
@Binding var ingredient: String
@Binding var servings: Double
@State var recipeYield: Double
@State var recipeId: String
let addToGroceryListAction: () -> Void
@State var modifiedIngredient: AttributedString = ""
@State var isSelected: Bool = false
@@ -110,7 +182,7 @@ fileprivate struct IngredientListItem: View {
var body: some View {
HStack(alignment: .top) {
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) {
if recipeGroceries.items.contains(ingredient) {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
@@ -168,7 +240,7 @@ fileprivate struct IngredientListItem: View {
.onEnded { gesture in
withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) {
if recipeGroceries.items.contains(ingredient) {
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else {
addToGroceryListAction()
@@ -182,7 +254,7 @@ fileprivate struct IngredientListItem: View {
)
}
}
*/
struct ServingPickerView: View {
@@ -217,4 +289,4 @@ struct ServingPickerView: View {
}
}
*/

View File

@@ -9,22 +9,26 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Instructions Section
/*
struct RecipeInstructionSection: View {
@State var viewModel: RecipeView.ViewModel
struct RecipeInstructionSection: View {
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentInstructionEditView: Bool
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer()
}
ForEach(viewModel.recipe.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.recipe.recipeInstructions[ix], index: ix+1)
ForEach(recipe.instructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1)
}
if viewModel.editMode {
if editMode {
Button {
viewModel.presentInstructionEditView.toggle()
presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
@@ -32,11 +36,10 @@ struct RecipeInstructionSection: View {
}
}
.padding()
}
}
// MARK: - Preview
fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String
@@ -56,4 +59,45 @@ fileprivate struct RecipeInstructionListItem: View {
.animation(.easeInOut, value: isSelected)
}
}
*/
struct RecipeInstructionSection_Previews: PreviewProvider {
static var previews: some View {
// Create a mock recipe
@State var mockRecipe = createRecipe()
// Create mock state variables for the @Binding properties
@State var mockEditMode = true
@State var mockPresentInstructionEditView = false
// Provide the mock data to the view
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Edit Mode")
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Read Only")
.environment(\.editMode, .constant(.inactive))
}
static func createRecipe() -> Recipe {
let recipe = Recipe()
recipe.name = "Mock Recipe"
recipe.instructions = [
"Step 1: Gather all ingredients and equipment.",
"Step 2: Preheat oven to 180°C (350°F) and prepare baking dish.",
"Step 3: Combine dry ingredients in a large bowl and mix thoroughly.",
"Step 4: In a separate bowl, whisk wet ingredients until smooth.",
"Step 5: Gradually add wet ingredients to dry ingredients, mixing until just combined. Do not overmix.",
"Step 6: Pour the mixture into the prepared baking dish and bake for 30-35 minutes, or until golden brown and a toothpick inserted into the center comes out clean.",
"Step 7: Let cool before serving. Enjoy!"
]
return recipe
}
}

View File

@@ -121,27 +121,27 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
.padding()
}
}
*/
// MARK: - RecipeView More Information Section
struct MoreInformationSection: View {
@State var viewModel: RecipeView.ViewModel
@Bindable var recipe: Recipe
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) {
if let dateCreated = viewModel.recipe.dateCreated {
if let dateCreated = recipe.dateCreated {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
}
if let dateModified = viewModel.recipe.dateModified {
if let dateModified = recipe.dateModified {
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
}
if viewModel.recipe.url != "", let url = URL(string: viewModel.recipe.url ?? "") {
if recipe.url != "", let url = URL(string: recipe.url ?? "") {
HStack(alignment: .top) {
Text("URL:")
Link(destination: url) {
Text(viewModel.recipe.url ?? "")
Text(recipe.url ?? "")
}
}
}
@@ -157,5 +157,3 @@ struct MoreInformationSection: View {
.padding()
}
}
*/

View File

@@ -9,14 +9,15 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Nutrition Section
/*
struct RecipeNutritionSection: View {
@State var viewModel: RecipeView.ViewModel
@Bindable var recipe: Recipe
@Binding var editMode: Bool
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
VStack(alignment: .leading) {
if viewModel.editMode {
if editMode {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
HStack {
Text(nutrition.localizedDescription)
@@ -28,7 +29,7 @@ struct RecipeNutritionSection: View {
} else if !nutritionEmpty() {
VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
if let value = viewModel.recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
if let value = recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
HStack(alignment: .top) {
Text("\(nutrition.localizedDescription): \(value)")
.multilineTextAlignment(.leading)
@@ -43,7 +44,7 @@ struct RecipeNutritionSection: View {
}
} title: {
HStack {
if let servingSize = viewModel.recipe.nutrition["servingSize"] {
if let servingSize = recipe.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))")
} else {
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
@@ -56,14 +57,14 @@ struct RecipeNutritionSection: View {
func binding(for key: String) -> Binding<String> {
Binding(
get: { viewModel.recipe.nutrition[key, default: ""] },
set: { viewModel.recipe.nutrition[key] = $0 }
get: { recipe.nutrition[key, default: ""] },
set: { recipe.nutrition[key] = $0 }
)
}
func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases {
if let value = viewModel.recipe.nutrition[nutrition.dictKey] {
if let value = recipe.nutrition[nutrition.dictKey] {
return false
}
}
@@ -71,4 +72,3 @@ struct RecipeNutritionSection: View {
}
}
*/

View File

@@ -9,9 +9,11 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Tool Section
/*
struct RecipeToolSection: View {
@State var viewModel: RecipeView.ViewModel
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentToolEditView: Bool
var body: some View {
VStack(alignment: .leading) {
@@ -20,11 +22,11 @@ struct RecipeToolSection: View {
Spacer()
}
RecipeListSection(list: $viewModel.recipe.tool)
RecipeListSection(list: $recipe.tools)
if viewModel.editMode {
if editMode {
Button {
viewModel.presentToolEditView.toggle()
presentToolEditView.toggle()
} label: {
Text("Edit")
}
@@ -36,4 +38,4 @@ struct RecipeToolSection: View {
}
*/

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
struct CollapsibleView<C: View, T: View>: View {
@State var titleColor: Color = .white
@State var isCollapsed: Bool = true
@@ -48,4 +48,4 @@ struct CollapsibleView<C: View, T: View>: View {
}
}
}
*/

View File

@@ -0,0 +1,38 @@
//
// ListVStack.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import SwiftUI
struct ListVStack<Element, HeaderContent: View, RowContent: View>: View {
@Binding var items: [Element]
let header: () -> HeaderContent
let rows: (Int, Binding<Element>) -> RowContent
init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding<Element>) -> RowContent) {
self._items = items
self.header = header
self.rows = rows
}
var body: some View {
VStack(alignment: .leading) {
header()
.padding(.horizontal, 30)
VStack(alignment: .leading, spacing: 0) {
ForEach(items.indices, id: \.self) { index in
rows(index, $items[index])
.padding(10)
}
}
.padding(4)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal)
}
}
}

View File

@@ -7,55 +7,158 @@
import Foundation
import SwiftUI
import SwiftData
@Model class GroceryItem {
var name: String
var isChecked: Bool
init(name: String, isChecked: Bool) {
self.name = name
self.isChecked = isChecked
}
}
@Model class RecipeGroceries: Identifiable {
var id: String
var name: String
@Relationship(deleteRule: .cascade) var items: [GroceryItem]
var multiplier: Double
init(id: String, name: String, items: [GroceryItem], multiplier: Double) {
self.id = id
self.name = name
self.items = items
self.multiplier = multiplier
}
init(id: String, name: String) {
self.id = id
self.name = name
self.items = []
self.multiplier = 1
}
}
/*
struct GroceryListTabView: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.modelContext) var modelContext
@Query var groceryList: [RecipeGroceries] = []
@State var newGroceries: String = ""
@FocusState private var isFocused: Bool
var body: some View {
NavigationStack {
if cookbookState.groceryList.groceryDict.isEmpty {
EmptyGroceryListView()
} else {
List {
ForEach(cookbookState.groceryList.groceryDict.keys.sorted(), id: \.self) { key in
Section {
ForEach(cookbookState.groceryList.groceryDict[key]!.items) { item in
GroceryListItemView(item: item, toggleAction: {
cookbookState.groceryList.toggleItemChecked(item)
}, deleteAction: {
withAnimation {
cookbookState.groceryList.deleteItem(item.name, fromRecipe: key)
}
})
List {
HStack(alignment: .top) {
TextEditor(text: $newGroceries)
.padding(4)
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary).opacity(0.5))
.focused($isFocused)
Button {
if !newGroceries.isEmpty {
let items = newGroceries
.split(separator: "\n")
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
Task {
await addGroceryItems(items, toCategory: "Other", named: String(localized: "Other"))
}
} header: {
HStack {
Text(cookbookState.groceryList.groceryDict[key]!.name)
}
newGroceries = ""
} label: {
Text("Add")
}
.disabled(newGroceries.isEmpty)
.buttonStyle(.borderedProminent)
}
ForEach(groceryList, id: \.name) { category in
Section {
ForEach(category.items, id: \.self) { item in
GroceryListItemView(item: item)
}
} header: {
HStack {
Text(category.name)
.foregroundStyle(Color.nextcloudBlue)
Spacer()
Button {
modelContext.delete(category)
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue)
Spacer()
Button {
cookbookState.groceryList.deleteGroceryRecipe(key)
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue)
}
}
}
}
}
.listStyle(.plain)
.navigationTitle("Grocery List")
.toolbar {
Button {
cookbookState.groceryList.deleteAll()
} label: {
Text("Delete")
.foregroundStyle(Color.nextcloudBlue)
}
if groceryList.isEmpty {
Text("You're all set for cooking 🍓")
.font(.headline)
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
.foregroundStyle(.secondary)
Text("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
}
}
.listStyle(.plain)
.navigationTitle("Grocery List")
.toolbar {
Button {
do {
try modelContext.delete(model: RecipeGroceries.self)
} catch {
print("Failed to delete all GroceryCategory models.")
}
} label: {
Text("Delete")
.foregroundStyle(Color.nextcloudBlue)
}
}
}
}
private func addGroceryItems(_ itemNames: [String], toCategory categoryId: String, named name: String) async {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
var targetCategory: RecipeGroceries?
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
targetCategory = existingCategory
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
targetCategory = newCategory
}
guard let category = targetCategory else { return }
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
category.items.append(newItem)
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
private func deleteGroceryItems(at offsets: IndexSet, in category: RecipeGroceries) {
for index in offsets {
let itemToDelete = category.items[index]
modelContext.delete(itemToDelete)
}
}
}
@@ -63,9 +166,8 @@ struct GroceryListTabView: View {
fileprivate struct GroceryListItemView: View {
let item: GroceryRecipeItem
let toggleAction: () -> Void
let deleteAction: () -> Void
@Environment(\.modelContext) var modelContext
@Bindable var item: GroceryItem
var body: some View {
HStack(alignment: .top) {
@@ -81,149 +183,13 @@ fileprivate struct GroceryListItemView: View {
}
.padding(5)
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture(perform: toggleAction)
.onTapGesture(perform: { item.isChecked.toggle() })
.animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(action: deleteAction) {
Button(action: { modelContext.delete(item) }) {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
fileprivate struct EmptyGroceryListView: View {
var body: some View {
List {
Text("You're all set for cooking 🍓")
.font(.headline)
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
}
.navigationTitle("Grocery List")
}
}
// Grocery List Logic
class GroceryRecipe: Identifiable, Codable {
let name: String
var items: [GroceryRecipeItem]
init(name: String, items: [GroceryRecipeItem]) {
self.name = name
self.items = items
}
init(name: String, item: GroceryRecipeItem) {
self.name = name
self.items = [item]
}
}
class GroceryRecipeItem: Identifiable, Codable {
let name: String
var isChecked: Bool
init(_ name: String, isChecked: Bool = false) {
self.name = name
self.isChecked = isChecked
}
}
@Observable class GroceryList {
let dataStore: DataStore = DataStore()
var groceryDict: [String: GroceryRecipe] = [:]
var sortBySimilarity: Bool = false
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("Adding item of recipe \(String(describing: recipeName))")
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
self.groceryDict[recipeId] = newRecipe
}
if saveGroceryDict {
self.save()
}
}
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
for item in items {
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
save()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
print("Deleting item \(itemName)")
guard let recipe = groceryDict[recipeId] else { return }
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
groceryDict[recipeId]?.items.remove(at: itemIndex)
if groceryDict[recipeId]!.items.isEmpty {
groceryDict.removeValue(forKey: recipeId)
}
save()
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
}
func deleteAll() {
print("Deleting all grocery items")
groceryDict = [:]
save()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
}
func containsItem(at recipeId: String, item: String) -> Bool {
guard let recipe = groceryDict[recipeId] else { return false }
if recipe.items.contains(where: { $0.name == item }) {
return true
}
return false
}
func containsRecipe(_ recipeId: String) -> Bool {
return groceryDict[recipeId] != nil
}
func save() {
Task {
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
}
}
func load() async {
do {
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
fromPath: "grocery_list.data"
) else { return }
self.groceryDict = groceryDict
} catch {
print("Unable to load grocery list")
}
}
}
*/

View File

@@ -7,88 +7,86 @@
import Foundation
import SwiftUI
import SwiftData
/*
struct RecipeTabView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
//@State var cookbookState: CookbookState = CookbookState()
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe]
@State var categories: [(String, Int)] = []
@State private var selectedRecipe: Recipe?
@State private var selectedCategory: String? = "*"
var body: some View {
NavigationSplitView {
List(selection: $viewModel.selectedCategory) {
// Categories
ForEach(appState.categories) { category in
NavigationLink(value: category) {
HStack(alignment: .center) {
if viewModel.selectedCategory != nil &&
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))
}
Spacer()
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
List(selection: $selectedCategory) {
CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*")
.tag("*") // Tag nil to select all recipes
Section("Categories") {
ForEach(categories, id: \.0.self) { category in
CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0)
.tag(category.0)
}
}
}
.navigationTitle("Cookbooks")
.toolbar {
RecipeTabViewToolBar()
}
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
SettingsView()
.environmentObject(appState)
}
.navigationDestination(isPresented: $viewModel.presentEditView) {
RecipeView(viewModel: RecipeView.ViewModel())
.environmentObject(appState)
.environmentObject(groceryList)
}
.navigationTitle("Categories")
} content: {
RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe)
} detail: {
NavigationStack {
if let category = viewModel.selectedCategory {
RecipeListView(
categoryName: category.name,
showEditView: $viewModel.presentEditView
)
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
}
// Use a conditional view based on selection
if let selectedRecipe {
//RecipeDetailView(recipe: recipe) // Create a dedicated detail view
RecipeView(recipe: selectedRecipe, viewModel: RecipeView.ViewModel(recipe: selectedRecipe))
} else {
ContentUnavailableView("Select a Recipe", systemImage: "fork.knife.circle")
}
}
.tint(.nextcloudBlue)
.task {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
initCategories()
return
do {
try modelContext.delete(model: Recipe.self)
} catch {
print("Failed to delete recipes and categories.")
}
guard let categories = await CookbookApiV1.getCategories(auth: UserSettings.shared.authString).0 else { return }
for category in categories {
guard let recipeStubs = await CookbookApiV1.getCategory(auth: UserSettings.shared.authString, named: category.name).0 else { return }
for recipeStub in recipeStubs {
guard let recipe = await CookbookApiV1.getRecipe(auth: UserSettings.shared.authString, id: recipeStub.id).0 else { return }
modelContext.insert(recipe)
}
}
}/*
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: {
//cookbookState.showSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
}
}*/
}
func initCategories() {
// Load Categories
var categoryDict: [String: Int] = [:]
for recipe in recipes {
// Ensure "Uncategorized" is a valid category if used
if !recipe.category.isEmpty {
categoryDict[recipe.category, default: 0] += 1
} else {
categoryDict["Other", default: 0] += 1
}
}
.refreshable {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
}
await appState.getCategories()
}
categories = categoryDict.map {
($0.key, $0.value)
}.sorted { $0.0 < $1.0 }
}
class ViewModel: ObservableObject {
@@ -98,13 +96,40 @@ struct RecipeTabView: View {
@Published var presentLoadingIndicator: Bool = false
@Published var presentConnectionPopover: Bool = false
@Published var serverConnection: Bool = false
@Published var selectedCategory: Category? = nil
}
}
fileprivate struct CategoryListItem: View {
var category: String
var count: Int
var isSelected: Bool
var body: some View {
HStack(alignment: .center) {
if isSelected {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
Text(category)
.font(.system(size: 20, weight: .medium, design: .default))
Spacer()
Text("\(count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
.minimumScaleFactor(0.5)
.background {
Circle()
.foregroundStyle(Color.secondary)
}
}.padding(7)
}
}
/*
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@EnvironmentObject var viewModel: RecipeTabView.ViewModel

View File

@@ -0,0 +1,234 @@
//
// SettingsTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import Foundation
import SwiftUI
struct SettingsTabView: View {
@ObservedObject var userSettings = UserSettings.shared
@State private var avatarImage: UIImage?
@State private var userData: UserData?
@State private var showAlert: Bool = false
@State private var alertType: SettingsAlert = .NONE
@State private var presentLoginSheet: Bool = false
enum SettingsAlert {
case LOG_OUT,
DELETE_CACHE,
NONE
func getTitle() -> String {
switch self {
case .LOG_OUT: return "Log out"
case .DELETE_CACHE: return "Delete local data"
default: return "Please confirm your action."
}
}
func getMessage() -> String {
switch self {
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
default: return ""
}
}
}
var body: some View {
Form {
Section {
if userSettings.authString.isEmpty {
HStack(alignment: .center) {
if let avatarImage = avatarImage {
Image(uiImage: avatarImage)
.resizable()
.clipShape(Circle())
.frame(width: 100, height: 100)
}
if let userData = userData {
VStack(alignment: .leading) {
Text(userData.userDisplayName)
.font(.title)
.padding(.leading)
Text("Username: \(userData.userId)")
.font(.subheadline)
.padding(.leading)
// TODO: Add actions
}
}
Spacer()
}
Button("Log out") {
print("Log out.")
alertType = .LOG_OUT
showAlert = true
}
.tint(.red)
} else {
Button("Log in") {
print("Log in.")
presentLoginSheet.toggle()
}
}
} header: {
Text("Nextcloud")
} footer: {
Text("Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed.")
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.keepScreenAwake) {
Text("Keep screen awake when viewing recipes")
}
}
Section {
HStack {
Text("Decimal number format")
Spacer()
Picker("", selection: $userSettings.decimalNumberSeparator) {
Text("Point (e.g. 1.42)").tag(".")
Text("Comma (e.g. 1,42)").tag(",")
}
.pickerStyle(.menu)
}
} footer: {
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
}
Toggle(isOn: $userSettings.storeImages) {
Text("Store recipe images locally")
}
Toggle(isOn: $userSettings.storeThumb) {
Text("Store recipe thumbnails locally")
}
} header: {
Text("Downloads")
} footer: {
Text("Configure what is stored on your device.")
}
Section {
Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
Text(lang.descriptor()).tag(lang.rawValue)
}
}
} footer: {
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
}
Section {
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
} header: {
Text("About")
} footer: {
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
}
Section {
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
} header: {
Text("Support")
} footer: {
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
}
Section {
Button("Delete local data") {
print("Clear cache.")
alertType = .DELETE_CACHE
showAlert = true
}
.tint(.red)
} header: {
Text("Other")
} footer: {
Text("Deleting local data will not affect the recipe data stored on your server.")
}
Section(header: Text("Acknowledgements")) {
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
Link("SwiftSoup", destination: url)
.font(.headline)
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
}
}
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
Link("TPPDF", destination: url)
.font(.headline)
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
}
}
}
}
.navigationTitle("Settings")
.alert(alertType.getTitle(), isPresented: $showAlert) {
Button("Cancel", role: .cancel) { }
if alertType == .DELETE_CACHE {
Button("Delete", role: .destructive) { deleteCachedData() }
}
} message: {
Text(alertType.getMessage())
}
.task {
await getUserData()
}
.sheet(isPresented: $presentLoginSheet, onDismiss: {}) {
V2LoginView()
}
}
func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar()
let (userData, _) = await NextcloudApi.getHoverCard()
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
}
func deleteCachedData() {
print("TODO: Delete cached data\n")
}
}