From 3cd19444967cff9d9957ffd6651e59b4c27b54d1 Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Wed, 1 Jun 2022 08:11:20 +0200 Subject: [PATCH 1/8] WIP CoreData integration - Add CoreData model. - Add CoreData Persistence controller. - Finalised BucketList CRUD. - WIP Cocktail CRUD. --- CocktailBucket.xcodeproj/project.pbxproj | 18 +++++ .../Shared.xcdatamodel/contents | 32 +++++++-- Shared/CocktailBucketApp.swift | 2 + Shared/Model/BucketListCD+Extensions.swift | 19 ++++++ Shared/Model/Cocktail+Extensions.swift | 14 ++++ Shared/Persistence.swift | 37 ++++++++++ Shared/Views/BucketListDetail.swift | 67 ++++++++++++------- Shared/Views/EditCocktail.swift | 1 + Shared/Views/Sidebar.swift | 64 ++++++++++++++++-- 9 files changed, 221 insertions(+), 33 deletions(-) create mode 100644 Shared/Model/BucketListCD+Extensions.swift create mode 100644 Shared/Model/Cocktail+Extensions.swift create mode 100644 Shared/Persistence.swift diff --git a/CocktailBucket.xcodeproj/project.pbxproj b/CocktailBucket.xcodeproj/project.pbxproj index 86f1b97..9f2d1a6 100644 --- a/CocktailBucket.xcodeproj/project.pbxproj +++ b/CocktailBucket.xcodeproj/project.pbxproj @@ -33,6 +33,12 @@ F9CD5D402679E5FA008AED32 /* BucketListDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D3E2679E5FA008AED32 /* BucketListDetail.swift */; }; F9CD5D422679E61C008AED32 /* EditCocktail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D412679E61C008AED32 /* EditCocktail.swift */; }; F9CD5D432679E61C008AED32 /* EditCocktail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D412679E61C008AED32 /* EditCocktail.swift */; }; + F9F9D4122844B26800DC4B06 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D4112844B26800DC4B06 /* Persistence.swift */; }; + F9F9D4132844B26800DC4B06 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D4112844B26800DC4B06 /* Persistence.swift */; }; + F9F9D41528466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */; }; + F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */; }; + F9F9D41828466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */; }; + F9F9D41928466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -51,6 +57,9 @@ F9CD5D3B2679E5CE008AED32 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; F9CD5D3E2679E5FA008AED32 /* BucketListDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketListDetail.swift; sourceTree = ""; }; F9CD5D412679E61C008AED32 /* EditCocktail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCocktail.swift; sourceTree = ""; }; + F9F9D4112844B26800DC4B06 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; + F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BucketListCD+Extensions.swift"; sourceTree = ""; }; + F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cocktail+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +94,7 @@ F9CD5D3A2679E5A1008AED32 /* Views */, F9CD5D2A2679CD78008AED32 /* Model */, F9CD5D092679CBEF008AED32 /* CocktailBucketApp.swift */, + F9F9D4112844B26800DC4B06 /* Persistence.swift */, F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */, F9CD5D072679CBEF008AED32 /* CocktailBucket.xcdatamodeld */, ); @@ -108,6 +118,8 @@ F9CD5D2E2679CDAF008AED32 /* Ingredient.swift */, F9CD5D372679D237008AED32 /* CocktailStore.swift */, F9629753267A222500BA814A /* Step.swift */, + F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */, + F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */, ); path = Model; sourceTree = ""; @@ -227,10 +239,13 @@ F9CD5D2F2679CDAF008AED32 /* Ingredient.swift in Sources */, F9CD5D422679E61C008AED32 /* EditCocktail.swift in Sources */, F9629754267A222500BA814A /* Step.swift in Sources */, + F9F9D41828466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */, F9CD5D1A2679CBF0008AED32 /* CocktailBucketApp.swift in Sources */, F9CD5D382679D237008AED32 /* CocktailStore.swift in Sources */, + F9F9D4122844B26800DC4B06 /* Persistence.swift in Sources */, F9629757267A23A200BA814A /* CocktailView.swift in Sources */, F9CD5D352679D00A008AED32 /* BucketList.swift in Sources */, + F9F9D41528466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, F9CD5D2C2679CD9E008AED32 /* Cocktail.swift in Sources */, F9CD5D3C2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1C2679CBF0008AED32 /* ContentView.swift in Sources */, @@ -246,10 +261,13 @@ F9CD5D302679CDAF008AED32 /* Ingredient.swift in Sources */, F9CD5D432679E61C008AED32 /* EditCocktail.swift in Sources */, F9629755267A222500BA814A /* Step.swift in Sources */, + F9F9D41928466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */, F9CD5D1B2679CBF0008AED32 /* CocktailBucketApp.swift in Sources */, F9CD5D392679D237008AED32 /* CocktailStore.swift in Sources */, + F9F9D4132844B26800DC4B06 /* Persistence.swift in Sources */, F9629758267A23A200BA814A /* CocktailView.swift in Sources */, F9CD5D362679D00A008AED32 /* BucketList.swift in Sources */, + F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, F9CD5D2D2679CD9E008AED32 /* Cocktail.swift in Sources */, F9CD5D3D2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1D2679CBF0008AED32 /* ContentView.swift in Sources */, diff --git a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents index e8d6ec8..bc04a2a 100644 --- a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents +++ b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents @@ -1,9 +1,33 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + - + + + + \ No newline at end of file diff --git a/Shared/CocktailBucketApp.swift b/Shared/CocktailBucketApp.swift index b4a0891..13fb6f8 100644 --- a/Shared/CocktailBucketApp.swift +++ b/Shared/CocktailBucketApp.swift @@ -10,10 +10,12 @@ import SwiftUI @main struct CocktailBucketApp: App { @StateObject private var store = CocktailStore() + @StateObject private var persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { ContentView() + .environment(\.managedObjectContext, persistenceController.container.viewContext) .environmentObject(store) } } diff --git a/Shared/Model/BucketListCD+Extensions.swift b/Shared/Model/BucketListCD+Extensions.swift new file mode 100644 index 0000000..5113010 --- /dev/null +++ b/Shared/Model/BucketListCD+Extensions.swift @@ -0,0 +1,19 @@ +// +// BucketListCD+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 31.05.22. +// + +import CoreData + +extension BucketListCD { + var wrappedName: String { + name ?? "" + } + + var wrappedCocktails: [CocktailCD] { + (cocktails as? Set ?? []) + .sorted(by: { $0.name ?? "" < $1.name ?? "" }) + } +} diff --git a/Shared/Model/Cocktail+Extensions.swift b/Shared/Model/Cocktail+Extensions.swift new file mode 100644 index 0000000..918cebb --- /dev/null +++ b/Shared/Model/Cocktail+Extensions.swift @@ -0,0 +1,14 @@ +// +// Cocktails+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 31.05.22. +// + +import CoreData + +extension CocktailCD { + var wrappedName: String { + name ?? "" + } +} diff --git a/Shared/Persistence.swift b/Shared/Persistence.swift new file mode 100644 index 0000000..8c314f8 --- /dev/null +++ b/Shared/Persistence.swift @@ -0,0 +1,37 @@ +// +// Persistence.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 30.05.22. +// + +import Foundation +import CoreData + +class PersistenceController: ObservableObject { + static let shared = PersistenceController() + + static var preview: PersistenceController = { + let result = PersistenceController(inMemory: true) + let viewContext = result.container.viewContext + + return result + }() + + let container: NSPersistentCloudKitContainer + + init(inMemory: Bool = false) { + container = NSPersistentCloudKitContainer(name: "CocktailBucket") + + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + container.viewContext.automaticallyMergesChangesFromParent = true + } +} diff --git a/Shared/Views/BucketListDetail.swift b/Shared/Views/BucketListDetail.swift index 38a1108..a9e3cf3 100644 --- a/Shared/Views/BucketListDetail.swift +++ b/Shared/Views/BucketListDetail.swift @@ -9,38 +9,44 @@ import SwiftUI struct BucketListDetail: View { - @Environment(\.isSearching) var isSearching + @Environment(\.isSearching) private var isSearching + @Environment(\.managedObjectContext) private var viewContext - @Binding var bucketList: BucketList + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \CocktailCD.name, ascending: true)]) + private var cocktails: FetchedResults @State private var search = "" @State private var editMode = false - @State private var currentCocktail = Cocktail() + @State private var currentCocktail: CocktailCD? - var cocktails: [Cocktail] { - guard !search.isEmpty else { return bucketList.cocktails} - return bucketList.cocktails.filter { $0.name.localizedCaseInsensitiveContains(search) } + var bucketList: BucketListCD + var predicate: NSPredicate + + init(bucketList: BucketListCD) { + self.bucketList = bucketList + self.predicate = NSPredicate(format: "bucketList == %@", bucketList) } var body: some View { List { - ForEach(cocktails) { cocktail in + ForEach(cocktails, id: \.self) { cocktail in NavigationLink { - CocktailView(cocktail: cocktail) +// CocktailView(cocktail: cocktail) + Text("Hello Cocktail ...") } label: { VStack(alignment: .leading) { - Text(cocktail.name) + Text(cocktail.wrappedName) - HStack { - ForEach(cocktail.ingredients.prefix(4)) { ingredient in - Text(ingredient.name) - .font(.caption).bold() - .padding(3) - .foregroundColor(Color.white) - .background(Color.accentColor.opacity(0.6)) - .cornerRadius(6) - } - } +// HStack { +// ForEach(cocktail.ingredients.prefix(4)) { ingredient in +// Text(ingredient.name) +// .font(.caption).bold() +// .padding(3) +// .foregroundColor(Color.white) +// .background(Color.accentColor.opacity(0.6)) +// .cornerRadius(6) +// } +// } } } .swipeActions { @@ -54,7 +60,8 @@ struct BucketListDetail: View { } .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { - bucketList[cocktail.id] = nil + viewContext.delete(cocktail) + try? viewContext.save() } label: { Label("Delete", systemImage: "trash") } @@ -63,19 +70,31 @@ struct BucketListDetail: View { } } .searchable(text: $search) - .navigationTitle(bucketList.name) + .navigationTitle(bucketList.wrappedName) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { editMode = true - currentCocktail = Cocktail() + currentCocktail = CocktailCD(context: viewContext) + currentCocktail?.name = "some new drink ?>?>" + currentCocktail?.bucketList = bucketList + try? viewContext.save() } label: { Label("Add new", systemImage: "plus") } } } - .sheet(isPresented: $editMode) { - EditCocktail(bucketList: $bucketList, cocktail: $currentCocktail) + .onChange(of: search) { newValue in + var predicates = [predicate] + if !newValue.isEmpty { + predicates.append(NSPredicate(format: "name CONTAINS[cd] %@", newValue)) + } + + cocktails.nsPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) } + .onAppear { cocktails.nsPredicate = predicate } +// .sheet(isPresented: $editMode) { +// EditCocktail(bucketList: $bucketList, cocktail: $currentCocktail) +// } } } diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index 9e1e4f5..eb132ce 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -13,6 +13,7 @@ struct EditCocktail: View { @Binding var bucketList: BucketList @Binding var cocktail: Cocktail + @Binding var cocktailCD: CocktailCD @State private var ingredientsExpanded = true @State private var stepsExpanded = true diff --git a/Shared/Views/Sidebar.swift b/Shared/Views/Sidebar.swift index c6a55f5..2473ffd 100644 --- a/Shared/Views/Sidebar.swift +++ b/Shared/Views/Sidebar.swift @@ -6,18 +6,72 @@ // import SwiftUI +import CoreData struct Sidebar: View { - @EnvironmentObject var store: CocktailStore + @Environment(\.managedObjectContext) private var viewContext + + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \BucketListCD.created, ascending: true)]) + private var buckets: FetchedResults + + @State private var addNewItem = false + @State private var name: String = "" + @State private var itemToAddOrEdit: BucketListCD? + @FocusState private var focus: String? var body: some View { - List($store.buckets) { $bucketList in - NavigationLink(bucketList.name) { - BucketListDetail(bucketList: $bucketList) + List { + ForEach(buckets) { bucketList in + if bucketList == itemToAddOrEdit { + TextField("Name", text: $name) + .focused($focus, equals: "name") + .onSubmit { + itemToAddOrEdit?.name = name + try? viewContext.save() + itemToAddOrEdit = nil + focus = nil + name = "" + } + } else { + NavigationLink(bucketList.wrappedName) { + BucketListDetail(bucketList: bucketList) + } + .badge(bucketList.cocktails?.count ?? 0) + .swipeActions { + Button { + itemToAddOrEdit = bucketList + name = itemToAddOrEdit?.name ?? "" + focus = "name" + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.gray) + } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + Button { + viewContext.delete(bucketList) + try? viewContext.save() + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } + } } - .badge(bucketList.cocktails.count) } .navigationTitle("Buckets") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + itemToAddOrEdit = BucketListCD(context: viewContext) + itemToAddOrEdit?.created = Date() + try? viewContext.save() + focus = "name" + } label: { + Label("Add new", systemImage: "plus") + } + } + } .listStyle(.sidebar) } } From cc5993fcdb884bf169d3d579138fa1a9b5f35515 Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Wed, 1 Jun 2022 22:37:26 +0200 Subject: [PATCH 2/8] WIP connect CoreData with EditCocktail. --- CocktailBucket.xcodeproj/project.pbxproj | 12 ++++ .../Shared.xcdatamodel/contents | 7 +- Shared/Model/BucketListCD+Extensions.swift | 2 +- Shared/Model/Cocktail+Extensions.swift | 10 +++ Shared/Model/Ingredient+Extensions.swift | 22 ++++++ Shared/Model/Ingredient.swift | 12 ++++ Shared/Model/Step+Extensions.swift | 14 ++++ Shared/Views/BucketListDetail.swift | 28 ++++---- Shared/Views/EditCocktail.swift | 72 +++++++++++++------ 9 files changed, 138 insertions(+), 41 deletions(-) create mode 100644 Shared/Model/Ingredient+Extensions.swift create mode 100644 Shared/Model/Step+Extensions.swift diff --git a/CocktailBucket.xcodeproj/project.pbxproj b/CocktailBucket.xcodeproj/project.pbxproj index 9f2d1a6..dcf42cb 100644 --- a/CocktailBucket.xcodeproj/project.pbxproj +++ b/CocktailBucket.xcodeproj/project.pbxproj @@ -39,6 +39,10 @@ F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */; }; F9F9D41828466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */; }; F9F9D41928466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */; }; + F9F9D41B28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */; }; + F9F9D41C28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */; }; + F9F9D41E28473D1F00DC4B06 /* Step+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */; }; + F9F9D41F28473D1F00DC4B06 /* Step+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -60,6 +64,8 @@ F9F9D4112844B26800DC4B06 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BucketListCD+Extensions.swift"; sourceTree = ""; }; F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cocktail+Extensions.swift"; sourceTree = ""; }; + F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Ingredient+Extensions.swift"; sourceTree = ""; }; + F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Step+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -120,6 +126,8 @@ F9629753267A222500BA814A /* Step.swift */, F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */, F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */, + F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */, + F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */, ); path = Model; sourceTree = ""; @@ -234,6 +242,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F9F9D41E28473D1F00DC4B06 /* Step+Extensions.swift in Sources */, F9CD5D3F2679E5FA008AED32 /* BucketListDetail.swift in Sources */, F9CD5D182679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */, F9CD5D2F2679CDAF008AED32 /* Ingredient.swift in Sources */, @@ -244,6 +253,7 @@ F9CD5D382679D237008AED32 /* CocktailStore.swift in Sources */, F9F9D4122844B26800DC4B06 /* Persistence.swift in Sources */, F9629757267A23A200BA814A /* CocktailView.swift in Sources */, + F9F9D41B28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, F9CD5D352679D00A008AED32 /* BucketList.swift in Sources */, F9F9D41528466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, F9CD5D2C2679CD9E008AED32 /* Cocktail.swift in Sources */, @@ -256,6 +266,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F9F9D41F28473D1F00DC4B06 /* Step+Extensions.swift in Sources */, F9CD5D402679E5FA008AED32 /* BucketListDetail.swift in Sources */, F9CD5D192679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */, F9CD5D302679CDAF008AED32 /* Ingredient.swift in Sources */, @@ -266,6 +277,7 @@ F9CD5D392679D237008AED32 /* CocktailStore.swift in Sources */, F9F9D4132844B26800DC4B06 /* Persistence.swift in Sources */, F9629758267A23A200BA814A /* CocktailView.swift in Sources */, + F9F9D41C28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, F9CD5D362679D00A008AED32 /* BucketList.swift in Sources */, F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, F9CD5D2D2679CD9E008AED32 /* Cocktail.swift in Sources */, diff --git a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents index bc04a2a..495a21e 100644 --- a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents +++ b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents @@ -8,25 +8,26 @@ + - + - + - + diff --git a/Shared/Model/BucketListCD+Extensions.swift b/Shared/Model/BucketListCD+Extensions.swift index 5113010..a591439 100644 --- a/Shared/Model/BucketListCD+Extensions.swift +++ b/Shared/Model/BucketListCD+Extensions.swift @@ -14,6 +14,6 @@ extension BucketListCD { var wrappedCocktails: [CocktailCD] { (cocktails as? Set ?? []) - .sorted(by: { $0.name ?? "" < $1.name ?? "" }) + .sorted(by: { $0.wrappedName < $1.wrappedName }) } } diff --git a/Shared/Model/Cocktail+Extensions.swift b/Shared/Model/Cocktail+Extensions.swift index 918cebb..57764af 100644 --- a/Shared/Model/Cocktail+Extensions.swift +++ b/Shared/Model/Cocktail+Extensions.swift @@ -11,4 +11,14 @@ extension CocktailCD { var wrappedName: String { name ?? "" } + + var wrappedIngredients: [IngredientCD] { + (ingredients as? Set ?? []) + .sorted(by: { $0.wrappedName < $1.wrappedName }) + } + + var wrappedSteps: [StepCD] { + (steps as? Set ?? []) + .sorted(by: { $0.wrappedStep < $1.wrappedStep }) + } } diff --git a/Shared/Model/Ingredient+Extensions.swift b/Shared/Model/Ingredient+Extensions.swift new file mode 100644 index 0000000..ff9ebbb --- /dev/null +++ b/Shared/Model/Ingredient+Extensions.swift @@ -0,0 +1,22 @@ +// +// Ingredient+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 01.06.22. +// + +import CoreData + +extension IngredientCD { + var wrappedName: String { + name ?? "" + } + + var unitEnum: Unit { + get { + guard let unit = unit else { return .ml } + return Unit(rawValue: unit) ?? Unit.ml + } + set { self.unit = newValue.rawValue } + } +} diff --git a/Shared/Model/Ingredient.swift b/Shared/Model/Ingredient.swift index c3a7955..d094651 100644 --- a/Shared/Model/Ingredient.swift +++ b/Shared/Model/Ingredient.swift @@ -37,3 +37,15 @@ struct Ingredient: Codable, Identifiable { var quantity: Double? var unit: Unit = .ml } + +struct IngredientVO: Codable, Identifiable { + var id: UUID? + var name: String = "" + var quantity: Double? + var unit: Unit = .ml +} + +struct StepVO: Codable, Identifiable { + var id: UUID? + var step: String = "" +} diff --git a/Shared/Model/Step+Extensions.swift b/Shared/Model/Step+Extensions.swift new file mode 100644 index 0000000..173c6f4 --- /dev/null +++ b/Shared/Model/Step+Extensions.swift @@ -0,0 +1,14 @@ +// +// Step+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 01.06.22. +// + +import CoreData + +extension StepCD { + var wrappedStep: String { + step ?? "" + } +} diff --git a/Shared/Views/BucketListDetail.swift b/Shared/Views/BucketListDetail.swift index a9e3cf3..095e07a 100644 --- a/Shared/Views/BucketListDetail.swift +++ b/Shared/Views/BucketListDetail.swift @@ -37,16 +37,16 @@ struct BucketListDetail: View { VStack(alignment: .leading) { Text(cocktail.wrappedName) -// HStack { -// ForEach(cocktail.ingredients.prefix(4)) { ingredient in -// Text(ingredient.name) -// .font(.caption).bold() -// .padding(3) -// .foregroundColor(Color.white) -// .background(Color.accentColor.opacity(0.6)) -// .cornerRadius(6) -// } -// } + HStack { + ForEach(cocktail.wrappedIngredients.prefix(4)) { ingredient in + Text(ingredient.wrappedName) + .font(.caption).bold() + .padding(3) + .foregroundColor(Color.white) + .background(Color.accentColor.opacity(0.6)) + .cornerRadius(6) + } + } } } .swipeActions { @@ -76,9 +76,7 @@ struct BucketListDetail: View { Button { editMode = true currentCocktail = CocktailCD(context: viewContext) - currentCocktail?.name = "some new drink ?>?>" currentCocktail?.bucketList = bucketList - try? viewContext.save() } label: { Label("Add new", systemImage: "plus") } @@ -93,8 +91,8 @@ struct BucketListDetail: View { cocktails.nsPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) } .onAppear { cocktails.nsPredicate = predicate } -// .sheet(isPresented: $editMode) { -// EditCocktail(bucketList: $bucketList, cocktail: $currentCocktail) -// } + .sheet(isPresented: $editMode) { + EditCocktail(cocktailCD: $currentCocktail) + } } } diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index eb132ce..40e6934 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -10,10 +10,14 @@ import SwiftUI struct EditCocktail: View { @Environment(\.presentationMode) var presentationMode + @Environment(\.managedObjectContext) private var viewContext - @Binding var bucketList: BucketList - @Binding var cocktail: Cocktail - @Binding var cocktailCD: CocktailCD + @Binding var cocktailCD: CocktailCD! + + @State private var name = "" + @State private var withAlcohol = false + @State private var ingredients: [IngredientVO] = [] + @State private var steps: [StepVO] = [] @State private var ingredientsExpanded = true @State private var stepsExpanded = true @@ -24,25 +28,25 @@ struct EditCocktail: View { NavigationView { Form { Section { - TextField("Name", text: $cocktail.name) + TextField("Name", text: $name) .autocapitalization(.words) .disableAutocorrection(true) - Toggle("Contain alcohol 🥴", isOn: $cocktail.alcohol) + Toggle("Contain alcohol 🥴", isOn: $withAlcohol) } DisclosureGroup(isExpanded: $ingredientsExpanded) { VStack { - List($cocktail.ingredients) { $ingredient in + List($ingredients) { $ingredient in IngredientView(focus: $focus, ingredient: $ingredient) } Button("Add Ingredient") { - let id = UUID().uuidString - cocktail.ingredients.append(Ingredient(id: id)) + let id = UUID() + ingredients.append(IngredientVO(id: id)) // Workaround delay for the focus to work DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focus = id + focus = id.uuidString } } } @@ -52,20 +56,20 @@ struct EditCocktail: View { DisclosureGroup(isExpanded: $stepsExpanded) { VStack { - List($cocktail.steps) { $step in + List($steps) { $step in VStack{ - TextEditor(text: $step.value) - .focused($focus, equals: step.id) + TextEditor(text: $step.step) + .focused($focus, equals: step.id?.uuidString) Divider() } } Button("Add Step") { - let id = UUID().uuidString - cocktail.steps.append(Step(id: id)) + let id = UUID() + steps.append(StepVO(id: id)) // Workaround delay for the focus to work DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focus = id + focus = id.uuidString } } } @@ -73,21 +77,45 @@ struct EditCocktail: View { Text("Steps").font(.title3).bold() } } - .navigationTitle("\(cocktail.id == nil ? "New" : "Update") Cocktail") + .navigationTitle("\(cocktailCD.uuid == nil ? "New" : "Update") Cocktail") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { presentationMode.wrappedValue.dismiss() } + Button("Cancel") { + viewContext.rollback() + presentationMode.wrappedValue.dismiss() + } } ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { - let id = cocktail.id ?? UUID().uuidString - cocktail.id = id - bucketList[id] = cocktail + if cocktailCD.uuid == nil { + cocktailCD.uuid = UUID() + } + + cocktailCD.name = name + cocktailCD.flags = withAlcohol ? 1 : 0 + // TODO: Save steps + // TODO: Save ingredients + presentationMode.wrappedValue.dismiss() } } } + .onAppear { + name = cocktailCD.wrappedName + withAlcohol = cocktailCD.flags == 1 + ingredients = cocktailCD.wrappedIngredients.map { + IngredientVO( + id: $0.uuid, + name: $0.wrappedName, + quantity: $0.quantity, + unit: $0.unitEnum + ) + } + steps = cocktailCD.wrappedSteps.map { + StepVO(id: $0.uuid, step: $0.wrappedStep) + } + } } } } @@ -95,7 +123,7 @@ struct EditCocktail: View { struct IngredientView: View { var focus: FocusState.Binding - @Binding var ingredient: Ingredient + @Binding var ingredient: IngredientVO var body: some View { VStack { @@ -103,7 +131,7 @@ struct IngredientView: View { TextField("Ingredient", text: $ingredient.name) .autocapitalization(.words) .disableAutocorrection(true) - .focused(focus, equals: ingredient.id) + .focused(focus, equals: ingredient.id?.uuidString) TextField("Quantity", value: $ingredient.quantity, formatter: NumberFormatter()) .multilineTextAlignment(.trailing) From 300683c28ad0872b00c5dffc58a3617271a90391 Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Sat, 4 Jun 2022 11:41:57 +0200 Subject: [PATCH 3/8] Integrate Add / Update cocktail with coreData --- Shared/Model/Ingredient.swift | 4 +- Shared/Views/EditCocktail.swift | 81 +++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/Shared/Model/Ingredient.swift b/Shared/Model/Ingredient.swift index d094651..4edc32f 100644 --- a/Shared/Model/Ingredient.swift +++ b/Shared/Model/Ingredient.swift @@ -39,13 +39,13 @@ struct Ingredient: Codable, Identifiable { } struct IngredientVO: Codable, Identifiable { - var id: UUID? + var id: UUID var name: String = "" var quantity: Double? var unit: Unit = .ml } struct StepVO: Codable, Identifiable { - var id: UUID? + var id: UUID var step: String = "" } diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index 40e6934..df32baf 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -59,7 +59,7 @@ struct EditCocktail: View { List($steps) { $step in VStack{ TextEditor(text: $step.step) - .focused($focus, equals: step.id?.uuidString) + .focused($focus, equals: step.id.uuidString) Divider() } } @@ -88,35 +88,66 @@ struct EditCocktail: View { } ToolbarItem(placement: .navigationBarTrailing) { Button("Save") { - if cocktailCD.uuid == nil { - cocktailCD.uuid = UUID() - } - - cocktailCD.name = name - cocktailCD.flags = withAlcohol ? 1 : 0 - // TODO: Save steps - // TODO: Save ingredients - + createOrUpdateCocktail() presentationMode.wrappedValue.dismiss() } } } - .onAppear { - name = cocktailCD.wrappedName - withAlcohol = cocktailCD.flags == 1 - ingredients = cocktailCD.wrappedIngredients.map { - IngredientVO( - id: $0.uuid, - name: $0.wrappedName, - quantity: $0.quantity, - unit: $0.unitEnum - ) - } - steps = cocktailCD.wrappedSteps.map { - StepVO(id: $0.uuid, step: $0.wrappedStep) - } + .onAppear(perform: fetchCocktailData) + } + } + + private func fetchCocktailData() { + name = cocktailCD.wrappedName + withAlcohol = cocktailCD.flags == 1 + ingredients = cocktailCD.wrappedIngredients.map { + IngredientVO( + id: $0.uuid!, + name: $0.wrappedName, + quantity: $0.quantity, + unit: $0.unitEnum + ) + } + steps = cocktailCD.wrappedSteps.map { + StepVO(id: $0.uuid!, step: $0.wrappedStep) + } + } + + private func createOrUpdateCocktail() { + if cocktailCD.uuid == nil { + cocktailCD.uuid = UUID() + } + + cocktailCD.name = name + cocktailCD.flags = withAlcohol ? 1 : 0 + + for ingredient in ingredients { + if let existing = cocktailCD.wrappedIngredients.first(where: { $0.uuid == ingredient.id }) { + existing.name = ingredient.name + existing.unitEnum = ingredient.unit + existing.quantity = ingredient.quantity ?? 0 + } else { + let newIngredient = IngredientCD(context: viewContext) + newIngredient.uuid = ingredient.id + newIngredient.cocktail = cocktailCD + newIngredient.name = ingredient.name + newIngredient.unitEnum = ingredient.unit + newIngredient.quantity = ingredient.quantity ?? 0 + } + } + + for step in steps { + if let existing = cocktailCD.wrappedSteps.first(where: { $0.uuid == step.id} ) { + existing.step = step.step + } else { + let newStep = StepCD(context: viewContext) + newStep.uuid = step.id + newStep.cocktail = cocktailCD + newStep.step = step.step } } + + try? viewContext.save() } } @@ -131,7 +162,7 @@ struct IngredientView: View { TextField("Ingredient", text: $ingredient.name) .autocapitalization(.words) .disableAutocorrection(true) - .focused(focus, equals: ingredient.id?.uuidString) + .focused(focus, equals: ingredient.id.uuidString) TextField("Quantity", value: $ingredient.quantity, formatter: NumberFormatter()) .multilineTextAlignment(.trailing) From 99c4a947ca05b439742c04fef39936e5f841d00c Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Sat, 4 Jun 2022 12:04:41 +0200 Subject: [PATCH 4/8] Remove old Models object and CocktailBucketStore --- CocktailBucket.xcodeproj/project.pbxproj | 32 +-------- Shared/CocktailBucketApp.swift | 2 - Shared/Model/BucketList.swift | 46 ------------- Shared/Model/Cocktail.swift | 23 ------- Shared/Model/CocktailStore.swift | 83 ------------------------ Shared/Model/Ingredient+Extensions.swift | 24 +++++++ Shared/Model/Ingredient.swift | 51 --------------- Shared/Model/Step.swift | 13 ---- Shared/Views/BucketListDetail.swift | 3 +- Shared/Views/CocktailView.swift | 42 +++++------- Shared/Views/ContentView.swift | 1 - Shared/Views/EditCocktail.swift | 12 ++++ 12 files changed, 56 insertions(+), 276 deletions(-) delete mode 100644 Shared/Model/BucketList.swift delete mode 100644 Shared/Model/Cocktail.swift delete mode 100644 Shared/Model/CocktailStore.swift delete mode 100644 Shared/Model/Ingredient.swift delete mode 100644 Shared/Model/Step.swift diff --git a/CocktailBucket.xcodeproj/project.pbxproj b/CocktailBucket.xcodeproj/project.pbxproj index dcf42cb..eeb6470 100644 --- a/CocktailBucket.xcodeproj/project.pbxproj +++ b/CocktailBucket.xcodeproj/project.pbxproj @@ -7,8 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - F9629754267A222500BA814A /* Step.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9629753267A222500BA814A /* Step.swift */; }; - F9629755267A222500BA814A /* Step.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9629753267A222500BA814A /* Step.swift */; }; F9629757267A23A200BA814A /* CocktailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9629756267A23A200BA814A /* CocktailView.swift */; }; F9629758267A23A200BA814A /* CocktailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9629756267A23A200BA814A /* CocktailView.swift */; }; F9CD5D182679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D072679CBEF008AED32 /* CocktailBucket.xcdatamodeld */; }; @@ -19,14 +17,6 @@ F9CD5D1D2679CBF0008AED32 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D0A2679CBEF008AED32 /* ContentView.swift */; }; F9CD5D202679CBF0008AED32 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */; }; F9CD5D212679CBF0008AED32 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */; }; - F9CD5D2C2679CD9E008AED32 /* Cocktail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D2B2679CD9E008AED32 /* Cocktail.swift */; }; - F9CD5D2D2679CD9E008AED32 /* Cocktail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D2B2679CD9E008AED32 /* Cocktail.swift */; }; - F9CD5D2F2679CDAF008AED32 /* Ingredient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D2E2679CDAF008AED32 /* Ingredient.swift */; }; - F9CD5D302679CDAF008AED32 /* Ingredient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D2E2679CDAF008AED32 /* Ingredient.swift */; }; - F9CD5D352679D00A008AED32 /* BucketList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D342679D00A008AED32 /* BucketList.swift */; }; - F9CD5D362679D00A008AED32 /* BucketList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D342679D00A008AED32 /* BucketList.swift */; }; - F9CD5D382679D237008AED32 /* CocktailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D372679D237008AED32 /* CocktailStore.swift */; }; - F9CD5D392679D237008AED32 /* CocktailStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D372679D237008AED32 /* CocktailStore.swift */; }; F9CD5D3C2679E5CE008AED32 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D3B2679E5CE008AED32 /* Sidebar.swift */; }; F9CD5D3D2679E5CE008AED32 /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D3B2679E5CE008AED32 /* Sidebar.swift */; }; F9CD5D3F2679E5FA008AED32 /* BucketListDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D3E2679E5FA008AED32 /* BucketListDetail.swift */; }; @@ -46,7 +36,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - F9629753267A222500BA814A /* Step.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Step.swift; sourceTree = ""; }; F9629756267A23A200BA814A /* CocktailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CocktailView.swift; sourceTree = ""; }; F9CD5D082679CBEF008AED32 /* Shared.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Shared.xcdatamodel; sourceTree = ""; }; F9CD5D092679CBEF008AED32 /* CocktailBucketApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CocktailBucketApp.swift; sourceTree = ""; }; @@ -54,10 +43,6 @@ F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F9CD5D112679CBF0008AED32 /* CocktailBucket.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CocktailBucket.app; sourceTree = BUILT_PRODUCTS_DIR; }; F9CD5D172679CBF0008AED32 /* CocktailBucket.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CocktailBucket.app; sourceTree = BUILT_PRODUCTS_DIR; }; - F9CD5D2B2679CD9E008AED32 /* Cocktail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cocktail.swift; sourceTree = ""; }; - F9CD5D2E2679CDAF008AED32 /* Ingredient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ingredient.swift; sourceTree = ""; }; - F9CD5D342679D00A008AED32 /* BucketList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketList.swift; sourceTree = ""; }; - F9CD5D372679D237008AED32 /* CocktailStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CocktailStore.swift; sourceTree = ""; }; F9CD5D3B2679E5CE008AED32 /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; F9CD5D3E2679E5FA008AED32 /* BucketListDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketListDetail.swift; sourceTree = ""; }; F9CD5D412679E61C008AED32 /* EditCocktail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCocktail.swift; sourceTree = ""; }; @@ -101,8 +86,8 @@ F9CD5D2A2679CD78008AED32 /* Model */, F9CD5D092679CBEF008AED32 /* CocktailBucketApp.swift */, F9F9D4112844B26800DC4B06 /* Persistence.swift */, - F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */, F9CD5D072679CBEF008AED32 /* CocktailBucket.xcdatamodeld */, + F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */, ); path = Shared; sourceTree = ""; @@ -119,11 +104,6 @@ F9CD5D2A2679CD78008AED32 /* Model */ = { isa = PBXGroup; children = ( - F9CD5D342679D00A008AED32 /* BucketList.swift */, - F9CD5D2B2679CD9E008AED32 /* Cocktail.swift */, - F9CD5D2E2679CDAF008AED32 /* Ingredient.swift */, - F9CD5D372679D237008AED32 /* CocktailStore.swift */, - F9629753267A222500BA814A /* Step.swift */, F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */, F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */, F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */, @@ -245,18 +225,13 @@ F9F9D41E28473D1F00DC4B06 /* Step+Extensions.swift in Sources */, F9CD5D3F2679E5FA008AED32 /* BucketListDetail.swift in Sources */, F9CD5D182679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */, - F9CD5D2F2679CDAF008AED32 /* Ingredient.swift in Sources */, F9CD5D422679E61C008AED32 /* EditCocktail.swift in Sources */, - F9629754267A222500BA814A /* Step.swift in Sources */, F9F9D41828466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */, F9CD5D1A2679CBF0008AED32 /* CocktailBucketApp.swift in Sources */, - F9CD5D382679D237008AED32 /* CocktailStore.swift in Sources */, F9F9D4122844B26800DC4B06 /* Persistence.swift in Sources */, F9629757267A23A200BA814A /* CocktailView.swift in Sources */, F9F9D41B28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, - F9CD5D352679D00A008AED32 /* BucketList.swift in Sources */, F9F9D41528466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, - F9CD5D2C2679CD9E008AED32 /* Cocktail.swift in Sources */, F9CD5D3C2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1C2679CBF0008AED32 /* ContentView.swift in Sources */, ); @@ -269,18 +244,13 @@ F9F9D41F28473D1F00DC4B06 /* Step+Extensions.swift in Sources */, F9CD5D402679E5FA008AED32 /* BucketListDetail.swift in Sources */, F9CD5D192679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */, - F9CD5D302679CDAF008AED32 /* Ingredient.swift in Sources */, F9CD5D432679E61C008AED32 /* EditCocktail.swift in Sources */, - F9629755267A222500BA814A /* Step.swift in Sources */, F9F9D41928466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */, F9CD5D1B2679CBF0008AED32 /* CocktailBucketApp.swift in Sources */, - F9CD5D392679D237008AED32 /* CocktailStore.swift in Sources */, F9F9D4132844B26800DC4B06 /* Persistence.swift in Sources */, F9629758267A23A200BA814A /* CocktailView.swift in Sources */, F9F9D41C28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, - F9CD5D362679D00A008AED32 /* BucketList.swift in Sources */, F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, - F9CD5D2D2679CD9E008AED32 /* Cocktail.swift in Sources */, F9CD5D3D2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1D2679CBF0008AED32 /* ContentView.swift in Sources */, ); diff --git a/Shared/CocktailBucketApp.swift b/Shared/CocktailBucketApp.swift index 13fb6f8..22bb1f2 100644 --- a/Shared/CocktailBucketApp.swift +++ b/Shared/CocktailBucketApp.swift @@ -9,14 +9,12 @@ import SwiftUI @main struct CocktailBucketApp: App { - @StateObject private var store = CocktailStore() @StateObject private var persistenceController = PersistenceController.shared var body: some Scene { WindowGroup { ContentView() .environment(\.managedObjectContext, persistenceController.container.viewContext) - .environmentObject(store) } } } diff --git a/Shared/Model/BucketList.swift b/Shared/Model/BucketList.swift deleted file mode 100644 index 726153f..0000000 --- a/Shared/Model/BucketList.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// BucketList.swift -// CocktailBucket -// -// Created by Mrabti Idriss on 16.06.21. -// - -import Foundation - -struct BucketList: Codable, Identifiable { - var id: String - var name: String - var cocktails: [Cocktail] - - subscript(cocktailId: Cocktail.ID?) -> Cocktail? { - get { - if let id = cocktailId { - return cocktails.first(where: { $0.id == id }) ?? .defaultCocktail - } - return .defaultCocktail - } - - set(newValue) { - if let id = cocktailId { - guard let newValue = newValue else { - guard let index = cocktails.firstIndex(where: { $0.id == id }) else { return } - cocktails.remove(at: index) - return - } - - guard let index = cocktails.firstIndex(where: { $0.id == id }) else { - cocktails.append(newValue) - return - } - - cocktails[index] = newValue - } - } - } -} - -extension BucketList { - static var defaultBucketList: Self { - BucketList(id: UUID().uuidString, name: "New List", cocktails: []) - } -} diff --git a/Shared/Model/Cocktail.swift b/Shared/Model/Cocktail.swift deleted file mode 100644 index 0ce1a74..0000000 --- a/Shared/Model/Cocktail.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Cocktail.swift -// CocktailBucket -// -// Created by Mrabti Idriss on 16.06.21. -// - -import Foundation - -struct Cocktail: Codable, Identifiable { - var id: String? - var name: String = "" - var alcohol: Bool = false - var picture: String? - var ingredients: [Ingredient] = [] - var steps: [Step] = [] -} - -extension Cocktail { - static var defaultCocktail: Self { - Cocktail() - } -} diff --git a/Shared/Model/CocktailStore.swift b/Shared/Model/CocktailStore.swift deleted file mode 100644 index 71a7e8c..0000000 --- a/Shared/Model/CocktailStore.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// CocktailStore.swift -// CocktailBucket -// -// Created by Mrabti Idriss on 16.06.21. -// - -import Foundation - -final class CocktailStore: ObservableObject { - @Published var buckets: [BucketList] = [] - - init() { - buckets.append(contentsOf: [ - BucketList(id: UUID().uuidString, name: "⛱ Sommer", cocktails: [ - Cocktail(id: UUID().uuidString, - name: "Mojito", - alcohol: true, - picture: "file:////Users/imrabti/Downloads/mojito.jpg", - ingredients: [ - Ingredient(id: UUID().uuidString, name: "White Rum", quantity: 4, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Sugar", quantity: 2, unit: .tableSpoon), - Ingredient(id: UUID().uuidString, name: "Lime Juice", quantity: 3, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Club Soda", quantity: 6, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Mint Leaves", quantity: 6, unit: .piece), - Ingredient(id: UUID().uuidString, name: "Crushed Ice", quantity: 4, unit: .piece), - ], - steps: [ - Step(id: UUID().uuidString, value: "Add **Sugar** *Mint leaves* to the Collins Glass"), - Step(id: UUID().uuidString, value: "Pour Lime Juice into the Glass"), - Step(id: UUID().uuidString, value: "Crush **Lime Juice** Sugar Mint Leaves with Muddler"), - Step(id: UUID().uuidString, value: "Fill up the Glass with Crushed Ice"), - Step(id: UUID().uuidString, value: "Pour White Rum into the Glass") - ]), - Cocktail(id: UUID().uuidString, - name: "Aperol Spritz", - alcohol: true, - picture: "file:////Users/imrabti/Downloads/aperol.jpg", - ingredients: [ - Ingredient(id: UUID().uuidString, name: "Aperol", quantity: 3, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Prosecco", quantity: 6, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Club Soda", quantity: 3, unit: .cl) - ], - steps: [ - Step(id: UUID().uuidString, value: "Add Ice to the Glass"), - Step(id: UUID().uuidString, value: "Pour Aperol Prosecco Club Soda into the Glass"), - Step(id: UUID().uuidString, value: "Stir together"), - Step(id: UUID().uuidString, value: "Garnish with Orange Slice") - ]), - Cocktail(id: UUID().uuidString, - name: "Salty Puppy", - alcohol: false, - picture: "file:////Users/imrabti/Downloads/salty_puppy.jpg", - ingredients: [ - Ingredient(id: UUID().uuidString, name: "Grapefruit Juice", quantity: 9, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Tonic", quantity: 3, unit: .cl), - Ingredient(id: UUID().uuidString, name: "Ice", quantity: 4, unit: .piece) - ], - steps: [ - Step(id: UUID().uuidString, value: "Garnish with Salt"), - Step(id: UUID().uuidString, value: "Pour Grapefruit Juice Tonic into the Glass") - ]), - ]), - BucketList(id: UUID().uuidString, name: "🎉 Celebration", cocktails: []), - BucketList(id: UUID().uuidString, name: "🧑🏻‍💻 Productivity", cocktails: []) - ]) - } - - subscript(bucketListId: BucketList.ID?) -> BucketList { - get { - if let id = bucketListId { - return buckets.first(where: { $0.id == id }) ?? .defaultBucketList - } - return .defaultBucketList - } - - set(newValue) { - if let id = bucketListId { - buckets[buckets.firstIndex(where: { $0.id == id })!] = newValue - } - } - } -} diff --git a/Shared/Model/Ingredient+Extensions.swift b/Shared/Model/Ingredient+Extensions.swift index ff9ebbb..ec08b2b 100644 --- a/Shared/Model/Ingredient+Extensions.swift +++ b/Shared/Model/Ingredient+Extensions.swift @@ -7,6 +7,30 @@ import CoreData +enum Unit: String, Codable, CaseIterable { + case teeSpoon + case tableSpoon + case ml + case cl + case piece + + var label: String { + switch self { + case .teeSpoon: + return "tsp" + case .tableSpoon: + return "tbsp" + default: + return self.rawValue + } + } + + func format(_ quantity: Double?) -> String { + guard let quantity = quantity else { return "0 \(label)" } + return "\(quantity) \(label)" + } +} + extension IngredientCD { var wrappedName: String { name ?? "" diff --git a/Shared/Model/Ingredient.swift b/Shared/Model/Ingredient.swift deleted file mode 100644 index 4edc32f..0000000 --- a/Shared/Model/Ingredient.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Ingredient.swift -// CocktailBucket -// -// Created by Mrabti Idriss on 16.06.21. -// - -import Foundation - -enum Unit: String, Codable, CaseIterable { - case teeSpoon - case tableSpoon - case ml - case cl - case piece - - var label: String { - switch self { - case .teeSpoon: - return "tsp" - case .tableSpoon: - return "tbsp" - default: - return self.rawValue - } - } - - func format(_ quantity: Double?) -> String { - guard let quantity = quantity else { return "0 \(label)" } - return "\(quantity) \(label)" - } -} - -struct Ingredient: Codable, Identifiable { - var id: String - var name: String = "" - var quantity: Double? - var unit: Unit = .ml -} - -struct IngredientVO: Codable, Identifiable { - var id: UUID - var name: String = "" - var quantity: Double? - var unit: Unit = .ml -} - -struct StepVO: Codable, Identifiable { - var id: UUID - var step: String = "" -} diff --git a/Shared/Model/Step.swift b/Shared/Model/Step.swift deleted file mode 100644 index 00638c6..0000000 --- a/Shared/Model/Step.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Step.swift -// CocktailBucket -// -// Created by Mrabti Idriss on 16.06.21. -// - -import Foundation - -struct Step: Codable, Identifiable { - var id: String - var value: String = "" -} diff --git a/Shared/Views/BucketListDetail.swift b/Shared/Views/BucketListDetail.swift index 095e07a..8233db8 100644 --- a/Shared/Views/BucketListDetail.swift +++ b/Shared/Views/BucketListDetail.swift @@ -31,8 +31,7 @@ struct BucketListDetail: View { List { ForEach(cocktails, id: \.self) { cocktail in NavigationLink { -// CocktailView(cocktail: cocktail) - Text("Hello Cocktail ...") + CocktailView(cocktail: cocktail) } label: { VStack(alignment: .leading) { Text(cocktail.wrappedName) diff --git a/Shared/Views/CocktailView.swift b/Shared/Views/CocktailView.swift index 046f707..ee04840 100644 --- a/Shared/Views/CocktailView.swift +++ b/Shared/Views/CocktailView.swift @@ -14,28 +14,28 @@ struct CocktailView: View { @State private var ingredientsExpanded = true @State private var stepsExpanded = true - let cocktail: Cocktail + let cocktail: CocktailCD var body: some View { VStack { - if let pictureUrl = cocktail.picture { - AsyncImage(url: URL(string: pictureUrl)) { image in - image.resizable() - .scaledToFill() - .frame(maxWidth: 200, maxHeight: 200) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } placeholder: { - PicturePlaceHolder() - } - } else { +// if let pictureUrl = cocktail.picture { +// AsyncImage(url: URL(string: pictureUrl)) { image in +// image.resizable() +// .scaledToFill() +// .frame(maxWidth: 200, maxHeight: 200) +// .clipShape(RoundedRectangle(cornerRadius: 10)) +// } placeholder: { +// PicturePlaceHolder() +// } +// } else { PicturePlaceHolder() - } +// } List { DisclosureGroup(isExpanded: $ingredientsExpanded) { - ForEach(cocktail.ingredients) { ingredient in + ForEach(cocktail.wrappedIngredients) { ingredient in HStack { - Text("\(ingredient.unit.format(ingredient.quantity)) - \(ingredient.name)") + Text("\(ingredient.unitEnum.format(ingredient.quantity)) - \(ingredient.wrappedName)") } } } label: { @@ -43,7 +43,7 @@ struct CocktailView: View { } DisclosureGroup(isExpanded: $stepsExpanded) { - ForEach(cocktail.steps) { step in + ForEach(cocktail.wrappedSteps) { step in Text(step.markdown) } } label: { @@ -52,7 +52,7 @@ struct CocktailView: View { } } .background(Color(UIColor.systemGroupedBackground)) - .navigationTitle(cocktail.name) + .navigationTitle(cocktail.wrappedName) .navigationBarTitleDisplayMode(.inline) } } @@ -71,14 +71,8 @@ struct PicturePlaceHolder: View { } } -extension Step { +extension StepCD { var markdown: AttributedString { - try! AttributedString(markdown: value) - } -} - -struct CocktailView_Previews: PreviewProvider { - static var previews: some View { - CocktailView(cocktail: Cocktail(name: "Mojito")) + try! AttributedString(markdown: wrappedStep) } } diff --git a/Shared/Views/ContentView.swift b/Shared/Views/ContentView.swift index 6af8ae3..6d0572b 100644 --- a/Shared/Views/ContentView.swift +++ b/Shared/Views/ContentView.swift @@ -19,6 +19,5 @@ struct ContentView: View { struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() - .environmentObject(CocktailStore()) } } diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index df32baf..34e6b00 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -7,6 +7,18 @@ import SwiftUI +struct IngredientVO: Codable, Identifiable { + var id: UUID + var name: String = "" + var quantity: Double? + var unit: Unit = .ml +} + +struct StepVO: Codable, Identifiable { + var id: UUID + var step: String = "" +} + struct EditCocktail: View { @Environment(\.presentationMode) var presentationMode From 6f52677ee146bfcc8f42d4772812f5168652eb95 Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Sat, 4 Jun 2022 21:17:41 +0200 Subject: [PATCH 5/8] Rename all CoreData entities to remove the CD suffix --- .../Shared.xcdatamodel/contents | 28 ++++++++-------- Shared/Model/BucketListCD+Extensions.swift | 6 ++-- Shared/Model/Cocktail+Extensions.swift | 10 +++--- Shared/Model/Ingredient+Extensions.swift | 2 +- Shared/Model/Step+Extensions.swift | 2 +- Shared/Views/BucketListDetail.swift | 14 ++++---- Shared/Views/CocktailView.swift | 4 +-- Shared/Views/EditCocktail.swift | 32 +++++++++---------- Shared/Views/Sidebar.swift | 8 ++--- 9 files changed, 53 insertions(+), 53 deletions(-) diff --git a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents index 495a21e..ae8f935 100644 --- a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents +++ b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents @@ -1,34 +1,34 @@ - + - + - + - - - + + + - + - + - + - + - - - - + + + + \ No newline at end of file diff --git a/Shared/Model/BucketListCD+Extensions.swift b/Shared/Model/BucketListCD+Extensions.swift index a591439..cd634e5 100644 --- a/Shared/Model/BucketListCD+Extensions.swift +++ b/Shared/Model/BucketListCD+Extensions.swift @@ -7,13 +7,13 @@ import CoreData -extension BucketListCD { +extension BucketList { var wrappedName: String { name ?? "" } - var wrappedCocktails: [CocktailCD] { - (cocktails as? Set ?? []) + var wrappedCocktails: [Cocktail] { + (cocktails as? Set ?? []) .sorted(by: { $0.wrappedName < $1.wrappedName }) } } diff --git a/Shared/Model/Cocktail+Extensions.swift b/Shared/Model/Cocktail+Extensions.swift index 57764af..b9a9ead 100644 --- a/Shared/Model/Cocktail+Extensions.swift +++ b/Shared/Model/Cocktail+Extensions.swift @@ -7,18 +7,18 @@ import CoreData -extension CocktailCD { +extension Cocktail { var wrappedName: String { name ?? "" } - var wrappedIngredients: [IngredientCD] { - (ingredients as? Set ?? []) + var wrappedIngredients: [Ingredient] { + (ingredients as? Set ?? []) .sorted(by: { $0.wrappedName < $1.wrappedName }) } - var wrappedSteps: [StepCD] { - (steps as? Set ?? []) + var wrappedSteps: [Step] { + (steps as? Set ?? []) .sorted(by: { $0.wrappedStep < $1.wrappedStep }) } } diff --git a/Shared/Model/Ingredient+Extensions.swift b/Shared/Model/Ingredient+Extensions.swift index ec08b2b..0d8b13f 100644 --- a/Shared/Model/Ingredient+Extensions.swift +++ b/Shared/Model/Ingredient+Extensions.swift @@ -31,7 +31,7 @@ enum Unit: String, Codable, CaseIterable { } } -extension IngredientCD { +extension Ingredient { var wrappedName: String { name ?? "" } diff --git a/Shared/Model/Step+Extensions.swift b/Shared/Model/Step+Extensions.swift index 173c6f4..4567398 100644 --- a/Shared/Model/Step+Extensions.swift +++ b/Shared/Model/Step+Extensions.swift @@ -7,7 +7,7 @@ import CoreData -extension StepCD { +extension Step { var wrappedStep: String { step ?? "" } diff --git a/Shared/Views/BucketListDetail.swift b/Shared/Views/BucketListDetail.swift index 8233db8..ed686b1 100644 --- a/Shared/Views/BucketListDetail.swift +++ b/Shared/Views/BucketListDetail.swift @@ -12,17 +12,17 @@ struct BucketListDetail: View { @Environment(\.isSearching) private var isSearching @Environment(\.managedObjectContext) private var viewContext - @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \CocktailCD.name, ascending: true)]) - private var cocktails: FetchedResults + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Cocktail.name, ascending: true)]) + private var cocktails: FetchedResults @State private var search = "" @State private var editMode = false - @State private var currentCocktail: CocktailCD? + @State private var currentCocktail: Cocktail? - var bucketList: BucketListCD + var bucketList: BucketList var predicate: NSPredicate - init(bucketList: BucketListCD) { + init(bucketList: BucketList) { self.bucketList = bucketList self.predicate = NSPredicate(format: "bucketList == %@", bucketList) } @@ -74,7 +74,7 @@ struct BucketListDetail: View { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { editMode = true - currentCocktail = CocktailCD(context: viewContext) + currentCocktail = Cocktail(context: viewContext) currentCocktail?.bucketList = bucketList } label: { Label("Add new", systemImage: "plus") @@ -91,7 +91,7 @@ struct BucketListDetail: View { } .onAppear { cocktails.nsPredicate = predicate } .sheet(isPresented: $editMode) { - EditCocktail(cocktailCD: $currentCocktail) + EditCocktail(cocktail: $currentCocktail) } } } diff --git a/Shared/Views/CocktailView.swift b/Shared/Views/CocktailView.swift index ee04840..be424cf 100644 --- a/Shared/Views/CocktailView.swift +++ b/Shared/Views/CocktailView.swift @@ -14,7 +14,7 @@ struct CocktailView: View { @State private var ingredientsExpanded = true @State private var stepsExpanded = true - let cocktail: CocktailCD + let cocktail: Cocktail var body: some View { VStack { @@ -71,7 +71,7 @@ struct PicturePlaceHolder: View { } } -extension StepCD { +extension Step { var markdown: AttributedString { try! AttributedString(markdown: wrappedStep) } diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index 34e6b00..fd0c93a 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -24,7 +24,7 @@ struct EditCocktail: View { @Environment(\.presentationMode) var presentationMode @Environment(\.managedObjectContext) private var viewContext - @Binding var cocktailCD: CocktailCD! + @Binding var cocktail: Cocktail! @State private var name = "" @State private var withAlcohol = false @@ -89,7 +89,7 @@ struct EditCocktail: View { Text("Steps").font(.title3).bold() } } - .navigationTitle("\(cocktailCD.uuid == nil ? "New" : "Update") Cocktail") + .navigationTitle("\(cocktail.uuid == nil ? "New" : "Update") Cocktail") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -110,9 +110,9 @@ struct EditCocktail: View { } private func fetchCocktailData() { - name = cocktailCD.wrappedName - withAlcohol = cocktailCD.flags == 1 - ingredients = cocktailCD.wrappedIngredients.map { + name = cocktail.wrappedName + withAlcohol = cocktail.flags == 1 + ingredients = cocktail.wrappedIngredients.map { IngredientVO( id: $0.uuid!, name: $0.wrappedName, @@ -120,28 +120,28 @@ struct EditCocktail: View { unit: $0.unitEnum ) } - steps = cocktailCD.wrappedSteps.map { + steps = cocktail.wrappedSteps.map { StepVO(id: $0.uuid!, step: $0.wrappedStep) } } private func createOrUpdateCocktail() { - if cocktailCD.uuid == nil { - cocktailCD.uuid = UUID() + if cocktail.uuid == nil { + cocktail.uuid = UUID() } - cocktailCD.name = name - cocktailCD.flags = withAlcohol ? 1 : 0 + cocktail.name = name + cocktail.flags = withAlcohol ? 1 : 0 for ingredient in ingredients { - if let existing = cocktailCD.wrappedIngredients.first(where: { $0.uuid == ingredient.id }) { + if let existing = cocktail.wrappedIngredients.first(where: { $0.uuid == ingredient.id }) { existing.name = ingredient.name existing.unitEnum = ingredient.unit existing.quantity = ingredient.quantity ?? 0 } else { - let newIngredient = IngredientCD(context: viewContext) + let newIngredient = Ingredient(context: viewContext) newIngredient.uuid = ingredient.id - newIngredient.cocktail = cocktailCD + newIngredient.cocktail = cocktail newIngredient.name = ingredient.name newIngredient.unitEnum = ingredient.unit newIngredient.quantity = ingredient.quantity ?? 0 @@ -149,12 +149,12 @@ struct EditCocktail: View { } for step in steps { - if let existing = cocktailCD.wrappedSteps.first(where: { $0.uuid == step.id} ) { + if let existing = cocktail.wrappedSteps.first(where: { $0.uuid == step.id} ) { existing.step = step.step } else { - let newStep = StepCD(context: viewContext) + let newStep = Step(context: viewContext) newStep.uuid = step.id - newStep.cocktail = cocktailCD + newStep.cocktail = cocktail newStep.step = step.step } } diff --git a/Shared/Views/Sidebar.swift b/Shared/Views/Sidebar.swift index 2473ffd..0dba803 100644 --- a/Shared/Views/Sidebar.swift +++ b/Shared/Views/Sidebar.swift @@ -11,12 +11,12 @@ import CoreData struct Sidebar: View { @Environment(\.managedObjectContext) private var viewContext - @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \BucketListCD.created, ascending: true)]) - private var buckets: FetchedResults + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \BucketList.created, ascending: true)]) + private var buckets: FetchedResults @State private var addNewItem = false @State private var name: String = "" - @State private var itemToAddOrEdit: BucketListCD? + @State private var itemToAddOrEdit: BucketList? @FocusState private var focus: String? var body: some View { @@ -63,7 +63,7 @@ struct Sidebar: View { .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { - itemToAddOrEdit = BucketListCD(context: viewContext) + itemToAddOrEdit = BucketList(context: viewContext) itemToAddOrEdit?.created = Date() try? viewContext.save() focus = "name" From e6b97740f47b82dfd6ce8a05134a9fb00ad5193c Mon Sep 17 00:00:00 2001 From: Mrabti Date: Tue, 7 Jun 2022 19:52:33 +0200 Subject: [PATCH 6/8] Cocktail picture support (#2) --- CocktailBucket.xcodeproj/project.pbxproj | 51 ++++- .../xcshareddata/swiftpm/Package.resolved | 14 ++ .../Shared.xcdatamodel/contents | 22 +- Shared/Model/Attachment+Extensions.swift | 171 ++++++++++++++++ ...ions.swift => BucketList+Extensions.swift} | 0 Shared/Model/Cocktail+Extensions.swift | 4 + Shared/Model/Ingredient+Extensions.swift | 11 + Shared/Persistence.swift | 15 ++ Shared/Views/BucketListDetail.swift | 29 ++- Shared/Views/CocktailView.swift | 66 +++--- Shared/Views/EditCocktail.swift | 191 +++++++++++++++--- Shared/Views/ImagePicker.swift | 93 +++++++++ 12 files changed, 596 insertions(+), 71 deletions(-) create mode 100644 CocktailBucket.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Shared/Model/Attachment+Extensions.swift rename Shared/Model/{BucketListCD+Extensions.swift => BucketList+Extensions.swift} (100%) create mode 100644 Shared/Views/ImagePicker.swift diff --git a/CocktailBucket.xcodeproj/project.pbxproj b/CocktailBucket.xcodeproj/project.pbxproj index eeb6470..05b589b 100644 --- a/CocktailBucket.xcodeproj/project.pbxproj +++ b/CocktailBucket.xcodeproj/project.pbxproj @@ -25,14 +25,19 @@ F9CD5D432679E61C008AED32 /* EditCocktail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9CD5D412679E61C008AED32 /* EditCocktail.swift */; }; F9F9D4122844B26800DC4B06 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D4112844B26800DC4B06 /* Persistence.swift */; }; F9F9D4132844B26800DC4B06 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D4112844B26800DC4B06 /* Persistence.swift */; }; - F9F9D41528466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */; }; - F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */; }; + F9F9D41528466ACC00DC4B06 /* BucketList+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketList+Extensions.swift */; }; + F9F9D41628466ACC00DC4B06 /* BucketList+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41428466ACC00DC4B06 /* BucketList+Extensions.swift */; }; F9F9D41828466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */; }; F9F9D41928466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */; }; F9F9D41B28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */; }; F9F9D41C28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */; }; F9F9D41E28473D1F00DC4B06 /* Step+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */; }; F9F9D41F28473D1F00DC4B06 /* Step+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */; }; + F9F9D421284DFC5600DC4B06 /* Attachment+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D420284DFC5600DC4B06 /* Attachment+Extensions.swift */; }; + F9F9D422284DFC5600DC4B06 /* Attachment+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D420284DFC5600DC4B06 /* Attachment+Extensions.swift */; }; + F9F9D424284E066100DC4B06 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D423284E066100DC4B06 /* ImagePicker.swift */; }; + F9F9D425284E066100DC4B06 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9F9D423284E066100DC4B06 /* ImagePicker.swift */; }; + F9F9D428284E06A300DC4B06 /* CropViewController in Frameworks */ = {isa = PBXBuildFile; productRef = F9F9D427284E06A300DC4B06 /* CropViewController */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -47,10 +52,12 @@ F9CD5D3E2679E5FA008AED32 /* BucketListDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketListDetail.swift; sourceTree = ""; }; F9CD5D412679E61C008AED32 /* EditCocktail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCocktail.swift; sourceTree = ""; }; F9F9D4112844B26800DC4B06 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; - F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BucketListCD+Extensions.swift"; sourceTree = ""; }; + F9F9D41428466ACC00DC4B06 /* BucketList+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BucketList+Extensions.swift"; sourceTree = ""; }; F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Cocktail+Extensions.swift"; sourceTree = ""; }; F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Ingredient+Extensions.swift"; sourceTree = ""; }; F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Step+Extensions.swift"; sourceTree = ""; }; + F9F9D420284DFC5600DC4B06 /* Attachment+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Attachment+Extensions.swift"; sourceTree = ""; }; + F9F9D423284E066100DC4B06 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -58,6 +65,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F9F9D428284E06A300DC4B06 /* CropViewController in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -104,10 +112,11 @@ F9CD5D2A2679CD78008AED32 /* Model */ = { isa = PBXGroup; children = ( - F9F9D41428466ACC00DC4B06 /* BucketListCD+Extensions.swift */, + F9F9D41428466ACC00DC4B06 /* BucketList+Extensions.swift */, F9F9D41728466DB900DC4B06 /* Cocktail+Extensions.swift */, F9F9D41A28473C7A00DC4B06 /* Ingredient+Extensions.swift */, F9F9D41D28473D1F00DC4B06 /* Step+Extensions.swift */, + F9F9D420284DFC5600DC4B06 /* Attachment+Extensions.swift */, ); path = Model; sourceTree = ""; @@ -120,6 +129,7 @@ F9CD5D3E2679E5FA008AED32 /* BucketListDetail.swift */, F9CD5D412679E61C008AED32 /* EditCocktail.swift */, F9629756267A23A200BA814A /* CocktailView.swift */, + F9F9D423284E066100DC4B06 /* ImagePicker.swift */, ); path = Views; sourceTree = ""; @@ -140,6 +150,9 @@ dependencies = ( ); name = "CocktailBucket (iOS)"; + packageProductDependencies = ( + F9F9D427284E06A300DC4B06 /* CropViewController */, + ); productName = "CocktailBucket (iOS)"; productReference = F9CD5D112679CBF0008AED32 /* CocktailBucket.app */; productType = "com.apple.product-type.application"; @@ -188,6 +201,9 @@ Base, ); mainGroup = F9CD5D012679CBEF008AED32; + packageReferences = ( + F9F9D426284E06A300DC4B06 /* XCRemoteSwiftPackageReference "TOCropViewController" */, + ); productRefGroup = F9CD5D122679CBF0008AED32 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -225,13 +241,15 @@ F9F9D41E28473D1F00DC4B06 /* Step+Extensions.swift in Sources */, F9CD5D3F2679E5FA008AED32 /* BucketListDetail.swift in Sources */, F9CD5D182679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */, + F9F9D424284E066100DC4B06 /* ImagePicker.swift in Sources */, F9CD5D422679E61C008AED32 /* EditCocktail.swift in Sources */, F9F9D41828466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */, F9CD5D1A2679CBF0008AED32 /* CocktailBucketApp.swift in Sources */, F9F9D4122844B26800DC4B06 /* Persistence.swift in Sources */, + F9F9D421284DFC5600DC4B06 /* Attachment+Extensions.swift in Sources */, F9629757267A23A200BA814A /* CocktailView.swift in Sources */, F9F9D41B28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, - F9F9D41528466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, + F9F9D41528466ACC00DC4B06 /* BucketList+Extensions.swift in Sources */, F9CD5D3C2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1C2679CBF0008AED32 /* ContentView.swift in Sources */, ); @@ -244,13 +262,15 @@ F9F9D41F28473D1F00DC4B06 /* Step+Extensions.swift in Sources */, F9CD5D402679E5FA008AED32 /* BucketListDetail.swift in Sources */, F9CD5D192679CBF0008AED32 /* CocktailBucket.xcdatamodeld in Sources */, + F9F9D425284E066100DC4B06 /* ImagePicker.swift in Sources */, F9CD5D432679E61C008AED32 /* EditCocktail.swift in Sources */, F9F9D41928466DB900DC4B06 /* Cocktail+Extensions.swift in Sources */, F9CD5D1B2679CBF0008AED32 /* CocktailBucketApp.swift in Sources */, F9F9D4132844B26800DC4B06 /* Persistence.swift in Sources */, + F9F9D422284DFC5600DC4B06 /* Attachment+Extensions.swift in Sources */, F9629758267A23A200BA814A /* CocktailView.swift in Sources */, F9F9D41C28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, - F9F9D41628466ACC00DC4B06 /* BucketListCD+Extensions.swift in Sources */, + F9F9D41628466ACC00DC4B06 /* BucketList+Extensions.swift in Sources */, F9CD5D3D2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1D2679CBF0008AED32 /* ContentView.swift in Sources */, ); @@ -525,6 +545,25 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + F9F9D426284E06A300DC4B06 /* XCRemoteSwiftPackageReference "TOCropViewController" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TimOliver/TOCropViewController.git"; + requirement = { + branch = main; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F9F9D427284E06A300DC4B06 /* CropViewController */ = { + isa = XCSwiftPackageProductDependency; + package = F9F9D426284E06A300DC4B06 /* XCRemoteSwiftPackageReference "TOCropViewController" */; + productName = CropViewController; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ F9CD5D072679CBEF008AED32 /* CocktailBucket.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/CocktailBucket.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CocktailBucket.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..66a4a1d --- /dev/null +++ b/CocktailBucket.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController.git", + "state" : { + "branch" : "main", + "revision" : "1d2cda4752a140a82f8a426def07b21fbae3a98f" + } + } + ], + "version" : 2 +} diff --git a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents index ae8f935..25088e5 100644 --- a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents +++ b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents @@ -1,5 +1,12 @@ + + + + + + + @@ -9,10 +16,15 @@ + + + + + @@ -26,9 +38,11 @@ - - - - + + + + + + \ No newline at end of file diff --git a/Shared/Model/Attachment+Extensions.swift b/Shared/Model/Attachment+Extensions.swift new file mode 100644 index 0000000..74a685c --- /dev/null +++ b/Shared/Model/Attachment+Extensions.swift @@ -0,0 +1,171 @@ +// +// Attachement+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 06.06.22. +// + +import Foundation +import CoreData +import UIKit + +extension Attachment { + + /** + Create the thumbnail URL for the current attachment. + */ + private func thumbnailURL() -> URL { + let fileName = uuid!.uuidString + ".thumbnail" + return PersistenceController.attachmentFolder.appendingPathComponent(fileName) + } + + /** + Create the image file URL for the current attachment. + */ + func imageURL() -> URL { + let fileName = uuid!.uuidString + ".jpg" + return PersistenceController.attachmentFolder.appendingPathComponent(fileName) + } + + /** + Create the thumbnail image from image data. + */ + static func thumbnail(from imageData: Data, thumbnailPixelSize: Int) -> UIImage? { + let options = [kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: thumbnailPixelSize] as CFDictionary + let imageSource = CGImageSourceCreateWithData(imageData as CFData, nil)! + let imageReference = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options)! + return UIImage(cgImage: imageReference) + } + + /** + Load the thumbnail data from the cached file, or from the imageData if the file doesn’t exist. + */ + func getThumbnail() -> UIImage? { + // Return the thumbnail image if it is already loaded. + guard thumbnail == nil else { return thumbnail } + + var nsError: NSError? + NSFileCoordinator().coordinate( + readingItemAt: thumbnailURL(), options: .withoutChanges, error: &nsError, + byAccessor: { (newURL: URL) -> Void in + thumbnail = UIImage(contentsOfFile: newURL.path) + } + ) + if let nsError = nsError { + print("###\(#function): \(nsError.localizedDescription)") + } + + // Return the thumbnail image if it is ready. + guard thumbnail == nil else { return thumbnail } + + // If the thumbnail doesn’t exist yet, try to create it from the full image data. + // For attachments created by Core Data with CloudKit, imageData can be nil. + guard let fullImageData = imageData?.data else { + print("###\(#function): Full image data is not there yet.") + return nil + } + thumbnail = Attachment.thumbnail(from: fullImageData, thumbnailPixelSize: 80) + return thumbnail + } + + /** + Load the image from the cached file if it exists, otherwise from the attachment’s imageData. + */ + func getImage(with taskContext: NSManagedObjectContext) -> UIImage? { + // Load the image from the cached file if the file exists. + var image: UIImage? + + var nsError: NSError? + NSFileCoordinator().coordinate( + readingItemAt: imageURL(), options: .withoutChanges, error: &nsError, + byAccessor: { (newURL: URL) -> Void in + if let data = try? Data(contentsOf: newURL) { + image = UIImage(data: data, scale: UIScreen.main.scale) + } + } + ) + if let nsError = nsError { + print("###\(#function): \(nsError.localizedDescription)") + } + + // Return the image if it was read from the cached file. + guard image == nil else { return image } + + // If the cache file doesn’t exist, load the image data from the store. + let attachmentObjectID = objectID + taskContext.performAndWait { + if let attachment = taskContext.object(with: attachmentObjectID) as? Attachment, + let data = attachment.imageData?.data { + image = UIImage(data: data, scale: UIScreen.main.scale) + } + } + return image + } + + /** + Cache the attachment image by saving it to a local file. Core Data does not persist the transient .thumbnail attribute. + */ + override public func didSave() { + super.didSave() + guard hasChanges else { return } + + let thumbnailFileURL = thumbnailURL() + let imageFileURL = imageURL() + + // If the attachment was marked as deleted, remove its files. + if isDeleted { + // Coordinate deletion because a peer can remove and sync the same attachment in the background. + coordinateDeleting(fileURL: thumbnailFileURL) + coordinateDeleting(fileURL: imageFileURL) + return + } + + // An attachment created by Core Data with CloudKit doesn’t have a thumbnail if it hasn’t been presented in this launch session. + guard let image = thumbnail else { return } + + // The attachment is valid, save the thumbnail jpeg data to a file asynchronously. + DispatchQueue.global().async { + guard let thumbnailData = image.jpegData(compressionQuality: 1) else { + return + } + var nsError: NSError? + NSFileCoordinator().coordinate( + writingItemAt: thumbnailFileURL, options: .forReplacing, error: &nsError, + byAccessor: { (newURL: URL) -> Void in + do { + try thumbnailData.write(to: newURL, options: .atomic) + } catch { + fatalError("###\(#function): Failed to save thumbnail file: \(newURL)") + } + } + ) + if let nsError = nsError { + print("###\(#function): \(nsError.localizedDescription)") + } + } + } + + /** + Coordinate deletion of attachments. + */ + private func coordinateDeleting(fileURL: URL) { + // fileExists is light so use it to avoid coordinate if the file doesn’t exist. + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + + var nsError: NSError? + NSFileCoordinator().coordinate( + writingItemAt: fileURL, options: .forDeleting, error: &nsError, byAccessor: { (newURL: URL) -> Void in + do { + try FileManager.default.removeItem(atPath: newURL.path) + } catch { + print("###\(#function): Failed to delete file at: \(fileURL)\n\(error)") + } + } + ) + if let nsError = nsError { + print("###\(#function): \(nsError.localizedDescription)") + } + } +} diff --git a/Shared/Model/BucketListCD+Extensions.swift b/Shared/Model/BucketList+Extensions.swift similarity index 100% rename from Shared/Model/BucketListCD+Extensions.swift rename to Shared/Model/BucketList+Extensions.swift diff --git a/Shared/Model/Cocktail+Extensions.swift b/Shared/Model/Cocktail+Extensions.swift index b9a9ead..6ef2b8c 100644 --- a/Shared/Model/Cocktail+Extensions.swift +++ b/Shared/Model/Cocktail+Extensions.swift @@ -21,4 +21,8 @@ extension Cocktail { (steps as? Set ?? []) .sorted(by: { $0.wrappedStep < $1.wrappedStep }) } + + var wrappedAttachment: Attachment? { + (attachments as? Set ?? []).first + } } diff --git a/Shared/Model/Ingredient+Extensions.swift b/Shared/Model/Ingredient+Extensions.swift index 0d8b13f..633b805 100644 --- a/Shared/Model/Ingredient+Extensions.swift +++ b/Shared/Model/Ingredient+Extensions.swift @@ -25,6 +25,17 @@ enum Unit: String, Codable, CaseIterable { } } + var defaultQuantity: Double { + switch self { + case .teeSpoon, .tableSpoon, .piece: + return 1 + case .ml: + return 20 + case .cl: + return 4 + } + } + func format(_ quantity: Double?) -> String { guard let quantity = quantity else { return "0 \(label)" } return "\(quantity) \(label)" diff --git a/Shared/Persistence.swift b/Shared/Persistence.swift index 8c314f8..17e0f98 100644 --- a/Shared/Persistence.swift +++ b/Shared/Persistence.swift @@ -18,6 +18,21 @@ class PersistenceController: ObservableObject { return result }() + static var attachmentFolder: URL = { + var url = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("CocktailBucket", isDirectory: true) + url = url.appendingPathComponent("attachments", isDirectory: true) + + // Create it if it doesn’t exist. + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } catch { + print("###\(#function): Failed to create thumbnail folder URL: \(error)") + } + } + return url + }() + let container: NSPersistentCloudKitContainer init(inMemory: Bool = false) { diff --git a/Shared/Views/BucketListDetail.swift b/Shared/Views/BucketListDetail.swift index ed686b1..abea4f9 100644 --- a/Shared/Views/BucketListDetail.swift +++ b/Shared/Views/BucketListDetail.swift @@ -33,17 +33,24 @@ struct BucketListDetail: View { NavigationLink { CocktailView(cocktail: cocktail) } label: { - VStack(alignment: .leading) { - Text(cocktail.wrappedName) - - HStack { - ForEach(cocktail.wrappedIngredients.prefix(4)) { ingredient in - Text(ingredient.wrappedName) - .font(.caption).bold() - .padding(3) - .foregroundColor(Color.white) - .background(Color.accentColor.opacity(0.6)) - .cornerRadius(6) + HStack(spacing: 8) { + CocktailPictureView( + size: 50, + placeholderBackgroundColor: Color(uiColor: .secondarySystemBackground), + picture: .constant(cocktail.wrappedAttachment?.getThumbnail()) + ) + VStack(alignment: .leading) { + Text(cocktail.wrappedName) + + HStack { + ForEach(cocktail.wrappedIngredients.prefix(4)) { ingredient in + Text(ingredient.wrappedName) + .font(.caption).bold() + .padding(3) + .foregroundColor(Color.white) + .background(Color.accentColor.opacity(0.6)) + .cornerRadius(6) + } } } } diff --git a/Shared/Views/CocktailView.swift b/Shared/Views/CocktailView.swift index be424cf..ee3343c 100644 --- a/Shared/Views/CocktailView.swift +++ b/Shared/Views/CocktailView.swift @@ -10,26 +10,17 @@ import SwiftUI struct CocktailView: View { @Environment(\.presentationMode) var presentationMode + @Environment(\.managedObjectContext) private var viewContext @State private var ingredientsExpanded = true @State private var stepsExpanded = true + @State private var picture: UIImage? let cocktail: Cocktail var body: some View { VStack { -// if let pictureUrl = cocktail.picture { -// AsyncImage(url: URL(string: pictureUrl)) { image in -// image.resizable() -// .scaledToFill() -// .frame(maxWidth: 200, maxHeight: 200) -// .clipShape(RoundedRectangle(cornerRadius: 10)) -// } placeholder: { -// PicturePlaceHolder() -// } -// } else { - PicturePlaceHolder() -// } + CocktailPictureView(size: 200, placeholderBackgroundColor: .white, picture: $picture) List { DisclosureGroup(isExpanded: $ingredientsExpanded) { @@ -42,31 +33,56 @@ struct CocktailView: View { Text("Ingredients").font(.title3).bold() } - DisclosureGroup(isExpanded: $stepsExpanded) { - ForEach(cocktail.wrappedSteps) { step in - Text(step.markdown) + if !cocktail.wrappedSteps.isEmpty { + DisclosureGroup(isExpanded: $stepsExpanded) { + ForEach(cocktail.wrappedSteps) { step in + Text(step.markdown) + } + } label: { + Text("Steps").font(.title3).bold() } - } label: { - Text("Steps").font(.title3).bold() } } } .background(Color(UIColor.systemGroupedBackground)) .navigationTitle(cocktail.wrappedName) .navigationBarTitleDisplayMode(.inline) + .onAppear { picture = cocktail.wrappedAttachment?.getImage(with: viewContext) } } } -struct PicturePlaceHolder: View { +struct CocktailPictureView: View { + + // MARK : - Properties + + let size: CGFloat + let placeholderBackgroundColor: Color + + @Binding var picture: UIImage? + var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 10) - .foregroundColor(.white) - .frame(width: 200, height: 200) - Image(systemName: "photo") - .foregroundColor(Color(UIColor.systemGray)) - .font(Font.system(.largeTitle)) + if let picture = picture { + Image(uiImage: picture) + .resizable() + .scaledToFill() + .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + ZStack { + RoundedRectangle(cornerRadius: 10) + .foregroundColor(placeholderBackgroundColor) + .frame(width: size + 2, height: size + 2) + + if size >= 200 { + Image(systemName: "photo") + .foregroundColor(Color(UIColor.systemGray)) + .font(Font.system(.largeTitle)) + } else { + Image(systemName: "photo") + .foregroundColor(Color(UIColor.systemGray)) + .imageScale(.medium) + } + } } } } diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index fd0c93a..3dbb68e 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -10,7 +10,7 @@ import SwiftUI struct IngredientVO: Codable, Identifiable { var id: UUID var name: String = "" - var quantity: Double? + var quantity: Double = Unit.ml.defaultQuantity var unit: Unit = .ml } @@ -26,19 +26,68 @@ struct EditCocktail: View { @Binding var cocktail: Cocktail! + @State private var sourceType = UIImagePickerController.SourceType.camera @State private var name = "" @State private var withAlcohol = false @State private var ingredients: [IngredientVO] = [] @State private var steps: [StepVO] = [] + @State private var picture: UIImage? + @State private var pictureHash: String? @State private var ingredientsExpanded = true @State private var stepsExpanded = true + @State private var editPicture = false @FocusState private var focus: String? + private var validForm: Bool { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + guard !ingredients.isEmpty else { return false } + guard ingredients.first(where: { $0.name.trimmingCharacters(in: .whitespaces).isEmpty }) == nil else { return false } + + return true + } + var body: some View { NavigationView { Form { + Section { + HStack { + Text("Picture") + + Spacer() + + Menu { + if UIImagePickerController.isSourceTypeAvailable(.camera) { + Button { + sourceType = .camera + editPicture = true + } label: { Label("Camera", systemImage: "camera") } + } + + if UIImagePickerController.isSourceTypeAvailable(.photoLibrary) { + Button { + sourceType = .photoLibrary + editPicture = true + } label: { Label("Photo library", systemImage: "photo.on.rectangle") } + } + + if picture != nil { + Button { + picture = nil + pictureHash = nil + } label: { Label("Delete photo", systemImage: "trash") } + } + } label: { + CocktailPictureView( + size: 80, + placeholderBackgroundColor: Color(uiColor: .secondarySystemBackground), + picture: $picture + ) + } + } + } + Section { TextField("Name", text: $name) .autocapitalization(.words) @@ -50,20 +99,18 @@ struct EditCocktail: View { DisclosureGroup(isExpanded: $ingredientsExpanded) { VStack { List($ingredients) { $ingredient in - IngredientView(focus: $focus, ingredient: $ingredient) - } - Button("Add Ingredient") { - let id = UUID() - ingredients.append(IngredientVO(id: id)) - - // Workaround delay for the focus to work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focus = id.uuidString - } + IngredientView(ingredient: $ingredient, focus: $focus, addIngredient: addIngredient) } } } label: { - Text("Ingredients").font(.title3).bold() + HStack { + Text("Ingredients").font(.title3).bold() + Spacer() + Button(action: addIngredient) { + Image(systemName: "plus.app.fill") + .imageScale(.large) + } + } } DisclosureGroup(isExpanded: $stepsExpanded) { @@ -75,18 +122,16 @@ struct EditCocktail: View { Divider() } } - Button("Add Step") { - let id = UUID() - steps.append(StepVO(id: id)) - - // Workaround delay for the focus to work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focus = id.uuidString - } - } } } label: { - Text("Steps").font(.title3).bold() + HStack { + Text("Steps").font(.title3).bold() + Spacer() + Button(action: addStep) { + Image(systemName: "plus.app.fill") + .imageScale(.large) + } + } } } .navigationTitle("\(cocktail.uuid == nil ? "New" : "Update") Cocktail") @@ -103,15 +148,47 @@ struct EditCocktail: View { createOrUpdateCocktail() presentationMode.wrappedValue.dismiss() } + .disabled(!validForm) } } .onAppear(perform: fetchCocktailData) + .sheet(isPresented: $editPicture) { + ImagePicker(image: $picture, pictureHash: $pictureHash, sourceType: $sourceType) + .edgesIgnoringSafeArea(.all) + } + } + } + + private func addIngredient() { + ingredientsExpanded = true + + let id = UUID() + ingredients.append(IngredientVO(id: id)) + + // Workaround delay for the focus to work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + focus = id.uuidString + } + } + + private func addStep() { + stepsExpanded = true + + let id = UUID() + steps.append(StepVO(id: id)) + + // Workaround delay for the focus to work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + focus = id.uuidString } } private func fetchCocktailData() { + picture = cocktail.wrappedAttachment?.getImage(with: viewContext) + pictureHash = cocktail.wrappedAttachment?.attachmentHash name = cocktail.wrappedName withAlcohol = cocktail.flags == 1 + ingredients = cocktail.wrappedIngredients.map { IngredientVO( id: $0.uuid!, @@ -120,6 +197,7 @@ struct EditCocktail: View { unit: $0.unitEnum ) } + steps = cocktail.wrappedSteps.map { StepVO(id: $0.uuid!, step: $0.wrappedStep) } @@ -137,14 +215,14 @@ struct EditCocktail: View { if let existing = cocktail.wrappedIngredients.first(where: { $0.uuid == ingredient.id }) { existing.name = ingredient.name existing.unitEnum = ingredient.unit - existing.quantity = ingredient.quantity ?? 0 + existing.quantity = ingredient.quantity } else { let newIngredient = Ingredient(context: viewContext) newIngredient.uuid = ingredient.id newIngredient.cocktail = cocktail newIngredient.name = ingredient.name newIngredient.unitEnum = ingredient.unit - newIngredient.quantity = ingredient.quantity ?? 0 + newIngredient.quantity = ingredient.quantity } } @@ -159,15 +237,77 @@ struct EditCocktail: View { } } + // Save or update the cocktail picture + createOrUpdateCocktailPicture() + try? viewContext.save() + viewContext.reset() + } + + private func createOrUpdateCocktailPicture() { + // In case the picture is nil check if there was an existing picture + // this mean that the user have removed the picture for this cocktail + guard let pictureData = picture?.jpegData(compressionQuality: 1), let pictureHash = pictureHash else { + guard let existingAttachment = cocktail.wrappedAttachment else { return } + cocktail.removeFromAttachments(existingAttachment) + existingAttachment.thumbnail = nil + viewContext.delete(existingAttachment) + + return + } + + // If the pictureHash is still the same then the picture wasn't updated + guard pictureHash != cocktail.wrappedAttachment?.attachmentHash else { return } + + let thumbnailImage = Attachment.thumbnail(from: pictureData, thumbnailPixelSize: 80) + var attachment: Attachment! + + // Update or create + if let existingAttachment = cocktail.wrappedAttachment { + attachment = existingAttachment + } else { + attachment = Attachment(context: viewContext) + attachment.uuid = UUID() + attachment.cocktail = cocktail + } + + attachment.attachmentHash = pictureHash + attachment.thumbnail = thumbnailImage + + // Create or update the ImageData + if let existingImageData = attachment.imageData { + existingImageData.data = pictureData + } else { + let newImageData = ImageData(context: viewContext) + newImageData.attachment = attachment + newImageData.data = pictureData + } + + // Save the full image to the attachment folder and use it as a cache. + DispatchQueue.global().async { + var nsError: NSError? + NSFileCoordinator().coordinate(writingItemAt: attachment.imageURL(), options: .forReplacing, error: &nsError, + byAccessor: { (newURL: URL) -> Void in + do { + try pictureData.write(to: newURL, options: .atomic) + } catch { + print("###\(#function): Failed to save an image file: \(attachment.imageURL())") + } + }) + if let nsError = nsError { + print("###\(#function): \(nsError.localizedDescription)") + } + } } } struct IngredientView: View { - var focus: FocusState.Binding @Binding var ingredient: IngredientVO + var focus: FocusState.Binding + var addIngredient: (() -> Void) + var body: some View { VStack { HStack { @@ -179,6 +319,7 @@ struct IngredientView: View { TextField("Quantity", value: $ingredient.quantity, formatter: NumberFormatter()) .multilineTextAlignment(.trailing) .keyboardType(.decimalPad) + .onSubmit(addIngredient) Picker(ingredient.unit.label, selection: $ingredient.unit) { ForEach(Unit.allCases, id: \.self) { item in diff --git a/Shared/Views/ImagePicker.swift b/Shared/Views/ImagePicker.swift new file mode 100644 index 0000000..7ba64ee --- /dev/null +++ b/Shared/Views/ImagePicker.swift @@ -0,0 +1,93 @@ +// +// ImagePicker.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 06.06.22. +// + +import SwiftUI +import CropViewController +import CryptoKit + +struct ImagePicker: UIViewControllerRepresentable { + + private let pictureArea: UIView = { + let view = UIView() + view.layer.borderColor = UIColor.white.cgColor + view.layer.borderWidth = 2 + view.layer.cornerRadius = 20 + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowRadius = 10.0 + view.layer.shadowOpacity = 0.9 + view.layer.shadowOffset = CGSize.zero + view.layer.masksToBounds = false + view.translatesAutoresizingMaskIntoConstraints = false + + return view + }() + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate, CropViewControllerDelegate { + let parent: ImagePicker + + init(_ parent: ImagePicker) { + self.parent = parent + } + + func imagePickerController(_ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + guard let image = info[.originalImage] as? UIImage else { + parent.presentationMode.wrappedValue.dismiss() + return + } + + let cropController = CropViewController(croppingStyle: CropViewCroppingStyle.default, image: image) + cropController.delegate = self + cropController.aspectRatioPreset = .presetSquare + cropController.aspectRatioLockEnabled = true + picker.pushViewController(cropController, animated: true) + } + + func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { + parent.image = image + parent.pictureHash = SHA256.hash(data: image.pngData()!).compactMap { String(format: "%02x", $0) }.joined() + parent.presentationMode.wrappedValue.dismiss() + } + + func cropViewController(_ cropViewController: CropViewController, didFinishCancelled cancelled: Bool) { + parent.presentationMode.wrappedValue.dismiss() + } + } + + @Environment(\.presentationMode) var presentationMode + @Binding var image: UIImage? + @Binding var pictureHash: String? + @Binding var sourceType: UIImagePickerController.SourceType + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.sourceType = sourceType + picker.delegate = context.coordinator + + if sourceType == .camera { + picker.cameraOverlayView = pictureArea + DispatchQueue.main.async { + NSLayoutConstraint.activate([ + pictureArea.centerXAnchor.constraint(equalTo: picker.view.centerXAnchor), + pictureArea.centerYAnchor.constraint(equalTo: picker.view.centerYAnchor), + pictureArea.widthAnchor.constraint(equalToConstant: 280), + pictureArea.heightAnchor.constraint(equalToConstant: 280) + ]) + } + } + + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, + context: UIViewControllerRepresentableContext) { + } +} From 904d8971f460f1d72ef7d40098ebef8a33f7ba6f Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Tue, 7 Jun 2022 22:44:53 +0200 Subject: [PATCH 7/8] Use cascade when convenient to delete elements, replace NSPersistentCloudKitContainer with NSPersistenContainer. --- .../Shared.xcdatamodel/contents | 16 ++++++++-------- Shared/Persistence.swift | 17 +++-------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents index 25088e5..a584b70 100644 --- a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents +++ b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents @@ -5,21 +5,21 @@ - + - + - + - - + + @@ -38,11 +38,11 @@ + - + + - - \ No newline at end of file diff --git a/Shared/Persistence.swift b/Shared/Persistence.swift index 17e0f98..edb91b2 100644 --- a/Shared/Persistence.swift +++ b/Shared/Persistence.swift @@ -11,13 +11,6 @@ import CoreData class PersistenceController: ObservableObject { static let shared = PersistenceController() - static var preview: PersistenceController = { - let result = PersistenceController(inMemory: true) - let viewContext = result.container.viewContext - - return result - }() - static var attachmentFolder: URL = { var url = NSPersistentContainer.defaultDirectoryURL().appendingPathComponent("CocktailBucket", isDirectory: true) url = url.appendingPathComponent("attachments", isDirectory: true) @@ -33,14 +26,10 @@ class PersistenceController: ObservableObject { return url }() - let container: NSPersistentCloudKitContainer + let container: NSPersistentContainer - init(inMemory: Bool = false) { - container = NSPersistentCloudKitContainer(name: "CocktailBucket") - - if inMemory { - container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") - } + init() { + container = NSPersistentContainer(name: "CocktailBucket") container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { From 0b0bf8075969fad1846bc107802c774b58c7b0ea Mon Sep 17 00:00:00 2001 From: Idriss Mrabti Date: Tue, 7 Jun 2022 22:54:37 +0200 Subject: [PATCH 8/8] Update EditCocktail.swift --- Shared/Views/EditCocktail.swift | 41 ++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/Shared/Views/EditCocktail.swift b/Shared/Views/EditCocktail.swift index 3dbb68e..d90bde1 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -12,6 +12,7 @@ struct IngredientVO: Codable, Identifiable { var name: String = "" var quantity: Double = Unit.ml.defaultQuantity var unit: Unit = .ml + var newItem: Bool } struct StepVO: Codable, Identifiable { @@ -163,7 +164,7 @@ struct EditCocktail: View { ingredientsExpanded = true let id = UUID() - ingredients.append(IngredientVO(id: id)) + ingredients.append(IngredientVO(id: id, newItem: true)) // Workaround delay for the focus to work DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { @@ -194,7 +195,8 @@ struct EditCocktail: View { id: $0.uuid!, name: $0.wrappedName, quantity: $0.quantity, - unit: $0.unitEnum + unit: $0.unitEnum, + newItem: false ) } @@ -309,26 +311,29 @@ struct IngredientView: View { var addIngredient: (() -> Void) var body: some View { - VStack { - HStack { - TextField("Ingredient", text: $ingredient.name) - .autocapitalization(.words) - .disableAutocorrection(true) - .focused(focus, equals: ingredient.id.uuidString) + HStack { + TextField("Ingredient", text: $ingredient.name) + .autocapitalization(.words) + .disableAutocorrection(true) + .focused(focus, equals: ingredient.id.uuidString) - TextField("Quantity", value: $ingredient.quantity, formatter: NumberFormatter()) - .multilineTextAlignment(.trailing) - .keyboardType(.decimalPad) - .onSubmit(addIngredient) + TextField("Quantity", value: $ingredient.quantity, formatter: NumberFormatter()) + .multilineTextAlignment(.trailing) + .keyboardType(.decimalPad) + .onSubmit(addIngredient) - Picker(ingredient.unit.label, selection: $ingredient.unit) { - ForEach(Unit.allCases, id: \.self) { item in - Text(item.rawValue) - } + Picker(ingredient.unit.label, selection: $ingredient.unit) { + ForEach(Unit.allCases, id: \.self) { item in + Text(item.rawValue) } - .pickerStyle(MenuPickerStyle()) } - Divider() + .pickerStyle(MenuPickerStyle()) + } + .onChange(of: ingredient.unit) { newUnit in + guard ingredient.newItem else { return } + ingredient.quantity = newUnit.defaultQuantity } + + Divider() } }