WIP - Complete App refactoring

This commit is contained in:
VincentMeilinger
2025-05-26 15:52:24 +02:00
parent 29fd3c668b
commit 5acf3b9c4f
49 changed files with 1996 additions and 543 deletions

View File

@@ -8,7 +8,7 @@
import Foundation
import SwiftUI
/*
struct RecipeView: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@@ -164,7 +164,7 @@ struct RecipeView: View {
let recipeDetail = await appState.getRecipe(
id: viewModel.recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? RecipeDetail.error
) ?? CookbookApiRecipeDetailV1.error
viewModel.setupView(recipeDetail: recipeDetail)
// Show download badge
@@ -182,7 +182,7 @@ struct RecipeView: View {
} else {
// Prepare view for a new recipe
viewModel.setupView(recipeDetail: RecipeDetail())
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
viewModel.editMode = true
viewModel.isDownloaded = false
}
@@ -231,8 +231,8 @@ struct RecipeView: View {
// MARK: - RecipeView ViewModel
class ViewModel: ObservableObject {
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail()
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
@Published var observableRecipeDetail: Recipe = Recipe()
@Published var recipeDetail: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1.error
@Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false
@Published var showTitle: Bool = false
@@ -244,7 +244,7 @@ struct RecipeView: View {
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe
var recipe: CookbookApiRecipeV1
var sharedURL: URL? = nil
var newRecipe: Bool = false
@@ -254,13 +254,13 @@ struct RecipeView: View {
var alertAction: () async -> () = { }
// Initializers
init(recipe: Recipe) {
init(recipe: CookbookApiRecipeV1) {
self.recipe = recipe
}
init() {
self.newRecipe = true
self.recipe = Recipe(
self.recipe = CookbookApiRecipeV1(
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
@@ -271,9 +271,9 @@ struct RecipeView: View {
}
// View setup
func setupView(recipeDetail: RecipeDetail) {
func setupView(recipeDetail: CookbookApiRecipeDetailV1) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
self.observableRecipeDetail = Recipe(recipeDetail)
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
@@ -458,4 +458,434 @@ struct RecipeViewToolBar: ToolbarContent {
}
}
*/
/*
struct RecipeView: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State 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 {
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) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.recipe.description != "" || viewModel.editMode {
EditableText(text: $viewModel.recipe.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.recipe.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.recipe.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.recipe.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.recipe.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.recipe.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.recipe.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.recipe.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// Load recipe detail
if let recipeStub = viewModel.recipeStub {
// For existing recipes, load the recipeDetail and image
let recipe = await cookbookState.selectedAccountState.getRecipe(
id: recipeStub.id
) ?? Recipe()
viewModel.recipe = recipe
// Show download badge
/*if viewModel.recipeStub!.storedLocally == nil {
viewModel.recipeStub?.storedLocally = cookbookState.selectedAccountState.recipeDetailExists(
recipeId: viewModel.recipe.recipe_id
)
}
viewModel.isDownloaded = viewModel.recipeStub!.storedLocally
*/
// Load recipe image
viewModel.recipeImage = await cookbookState.selectedAccountState.getImage(
id: recipeStub.id,
size: .FULL
)
} else {
// Prepare view for a new recipe
viewModel.editMode = true
viewModel.isDownloaded = false
viewModel.recipe = Recipe()
}
}
.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 && cookbookState.selectedAccountState.keywords.isEmpty {
Task {
if let keywords = await cookbookState.selectedAccountState.getTags()?.sorted(by: { a, b in
a.recipe_count > b.recipe_count
}) {
cookbookState.selectedAccountState.keywords = keywords
}
}
}
}
}
// MARK: - RecipeView ViewModel
@Observable class ViewModel {
var recipeImage: UIImage? = nil
var editMode: Bool = false
var showTitle: Bool = false
var isDownloaded: Bool? = nil
var importUrl: String = ""
var presentShareSheet: Bool = false
var presentInstructionEditView: Bool = false
var presentIngredientEditView: Bool = false
var presentToolEditView: Bool = false
var recipeStub: RecipeStub? = nil
var recipe: Recipe = Recipe()
var sharedURL: URL? = nil
var newRecipe: Bool = false
// Alerts
var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(recipeStub: RecipeStub) {
self.recipeStub = recipeStub
}
init() {
self.newRecipe = true
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
alertType = type
alertAction = action
presentAlert = true
}
}
}
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
/*let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
return nil
}*/
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.recipe = scrapedRecipe.toRecipe()
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
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 {
print("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
func handleUpload() async {
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await cookbookState.selectedAccountState.postRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
} else {
print("Uploading changed recipe.")
if let alert = await cookbookState.selectedAccountState.updateRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: viewModel.recipe.recipeCategory)
let _ = await cookbookState.selectedAccountState.getRecipe(id: viewModel.recipe.id)
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.recipe.recipeCategory
if let alert = await cookbookState.selectedAccountState.deleteRecipe(id: viewModel.recipe.id) {
viewModel.presentAlert(alert)
return
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: category)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in cookbookState.selectedAccountState.recipeStubs.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.recipe.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}
*/