WIP - Complete App refactoring
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user