diff --git a/CocktailBucket.xcodeproj/project.pbxproj b/CocktailBucket.xcodeproj/project.pbxproj index 86f1b97..05b589b 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,24 +17,30 @@ 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 */; }; 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 /* 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 */ - 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 = ""; }; @@ -44,13 +48,16 @@ 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 = ""; }; + F9F9D4112844B26800DC4B06 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.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; }; @@ -85,8 +93,9 @@ F9CD5D3A2679E5A1008AED32 /* Views */, F9CD5D2A2679CD78008AED32 /* Model */, F9CD5D092679CBEF008AED32 /* CocktailBucketApp.swift */, - F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */, + F9F9D4112844B26800DC4B06 /* Persistence.swift */, F9CD5D072679CBEF008AED32 /* CocktailBucket.xcdatamodeld */, + F9CD5D0C2679CBF0008AED32 /* Assets.xcassets */, ); path = Shared; sourceTree = ""; @@ -103,11 +112,11 @@ F9CD5D2A2679CD78008AED32 /* Model */ = { isa = PBXGroup; children = ( - F9CD5D342679D00A008AED32 /* BucketList.swift */, - F9CD5D2B2679CD9E008AED32 /* Cocktail.swift */, - F9CD5D2E2679CDAF008AED32 /* Ingredient.swift */, - F9CD5D372679D237008AED32 /* CocktailStore.swift */, - F9629753267A222500BA814A /* Step.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 = ""; @@ -222,16 +238,18 @@ 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 */, + F9F9D424284E066100DC4B06 /* ImagePicker.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 */, + F9F9D421284DFC5600DC4B06 /* Attachment+Extensions.swift in Sources */, F9629757267A23A200BA814A /* CocktailView.swift in Sources */, - F9CD5D352679D00A008AED32 /* BucketList.swift in Sources */, - F9CD5D2C2679CD9E008AED32 /* Cocktail.swift in Sources */, + F9F9D41B28473C7A00DC4B06 /* Ingredient+Extensions.swift in Sources */, + F9F9D41528466ACC00DC4B06 /* BucketList+Extensions.swift in Sources */, F9CD5D3C2679E5CE008AED32 /* Sidebar.swift in Sources */, F9CD5D1C2679CBF0008AED32 /* ContentView.swift in Sources */, ); @@ -241,16 +259,18 @@ 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 */, + F9F9D425284E066100DC4B06 /* ImagePicker.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 */, + F9F9D422284DFC5600DC4B06 /* Attachment+Extensions.swift in Sources */, F9629758267A23A200BA814A /* CocktailView.swift in Sources */, - F9CD5D362679D00A008AED32 /* BucketList.swift in Sources */, - F9CD5D2D2679CD9E008AED32 /* Cocktail.swift in Sources */, + F9F9D41C28473C7A00DC4B06 /* Ingredient+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 e8d6ec8..a584b70 100644 --- a/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents +++ b/Shared/CocktailBucket.xcdatamodeld/Shared.xcdatamodel/contents @@ -1,9 +1,48 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + \ No newline at end of file diff --git a/Shared/CocktailBucketApp.swift b/Shared/CocktailBucketApp.swift index b4a0891..22bb1f2 100644 --- a/Shared/CocktailBucketApp.swift +++ b/Shared/CocktailBucketApp.swift @@ -9,12 +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() - .environmentObject(store) + .environment(\.managedObjectContext, persistenceController.container.viewContext) } } } 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/BucketList+Extensions.swift b/Shared/Model/BucketList+Extensions.swift new file mode 100644 index 0000000..cd634e5 --- /dev/null +++ b/Shared/Model/BucketList+Extensions.swift @@ -0,0 +1,19 @@ +// +// BucketListCD+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 31.05.22. +// + +import CoreData + +extension BucketList { + var wrappedName: String { + name ?? "" + } + + var wrappedCocktails: [Cocktail] { + (cocktails as? Set ?? []) + .sorted(by: { $0.wrappedName < $1.wrappedName }) + } +} 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+Extensions.swift b/Shared/Model/Cocktail+Extensions.swift new file mode 100644 index 0000000..6ef2b8c --- /dev/null +++ b/Shared/Model/Cocktail+Extensions.swift @@ -0,0 +1,28 @@ +// +// Cocktails+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 31.05.22. +// + +import CoreData + +extension Cocktail { + var wrappedName: String { + name ?? "" + } + + var wrappedIngredients: [Ingredient] { + (ingredients as? Set ?? []) + .sorted(by: { $0.wrappedName < $1.wrappedName }) + } + + var wrappedSteps: [Step] { + (steps as? Set ?? []) + .sorted(by: { $0.wrappedStep < $1.wrappedStep }) + } + + var wrappedAttachment: Attachment? { + (attachments as? Set ?? []).first + } +} 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 new file mode 100644 index 0000000..633b805 --- /dev/null +++ b/Shared/Model/Ingredient+Extensions.swift @@ -0,0 +1,57 @@ +// +// Ingredient+Extensions.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 01.06.22. +// + +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 + } + } + + 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)" + } +} + +extension Ingredient { + 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 deleted file mode 100644 index c3a7955..0000000 --- a/Shared/Model/Ingredient.swift +++ /dev/null @@ -1,39 +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 -} diff --git a/Shared/Model/Step+Extensions.swift b/Shared/Model/Step+Extensions.swift new file mode 100644 index 0000000..4567398 --- /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 Step { + var wrappedStep: String { + step ?? "" + } +} 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/Persistence.swift b/Shared/Persistence.swift new file mode 100644 index 0000000..edb91b2 --- /dev/null +++ b/Shared/Persistence.swift @@ -0,0 +1,41 @@ +// +// Persistence.swift +// CocktailBucket +// +// Created by Mrabti Idriss on 30.05.22. +// + +import Foundation +import CoreData + +class PersistenceController: ObservableObject { + static let shared = PersistenceController() + + 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: NSPersistentContainer + + init() { + container = NSPersistentContainer(name: "CocktailBucket") + + 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..abea4f9 100644 --- a/Shared/Views/BucketListDetail.swift +++ b/Shared/Views/BucketListDetail.swift @@ -9,36 +9,48 @@ 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: \Cocktail.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: Cocktail? - var cocktails: [Cocktail] { - guard !search.isEmpty else { return bucketList.cocktails} - return bucketList.cocktails.filter { $0.name.localizedCaseInsensitiveContains(search) } + var bucketList: BucketList + var predicate: NSPredicate + + init(bucketList: BucketList) { + 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) } label: { - VStack(alignment: .leading) { - Text(cocktail.name) - - 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(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) + } } } } @@ -54,7 +66,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 +76,29 @@ struct BucketListDetail: View { } } .searchable(text: $search) - .navigationTitle(bucketList.name) + .navigationTitle(bucketList.wrappedName) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { editMode = true - currentCocktail = Cocktail() + currentCocktail = Cocktail(context: viewContext) + currentCocktail?.bucketList = bucketList } label: { Label("Add new", systemImage: "plus") } } } + .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) + EditCocktail(cocktail: $currentCocktail) } } } diff --git a/Shared/Views/CocktailView.swift b/Shared/Views/CocktailView.swift index 046f707..ee3343c 100644 --- a/Shared/Views/CocktailView.swift +++ b/Shared/Views/CocktailView.swift @@ -10,75 +10,85 @@ 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) { - 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: { Text("Ingredients").font(.title3).bold() } - DisclosureGroup(isExpanded: $stepsExpanded) { - ForEach(cocktail.steps) { 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.name) + .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) + } + } } } } extension Step { 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 9e1e4f5..d90bde1 100644 --- a/Shared/Views/EditCocktail.swift +++ b/Shared/Views/EditCocktail.swift @@ -7,115 +7,333 @@ import SwiftUI +struct IngredientVO: Codable, Identifiable { + var id: UUID + var name: String = "" + var quantity: Double = Unit.ml.defaultQuantity + var unit: Unit = .ml + var newItem: Bool +} + +struct StepVO: Codable, Identifiable { + var id: UUID + var step: String = "" +} + struct EditCocktail: View { @Environment(\.presentationMode) var presentationMode + @Environment(\.managedObjectContext) private var viewContext - @Binding var bucketList: BucketList - @Binding var cocktail: Cocktail + @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 { - TextField("Name", text: $cocktail.name) + 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) .disableAutocorrection(true) - Toggle("Contain alcohol 🥴", isOn: $cocktail.alcohol) + Toggle("Contain alcohol 🥴", isOn: $withAlcohol) } DisclosureGroup(isExpanded: $ingredientsExpanded) { VStack { - List($cocktail.ingredients) { $ingredient in - IngredientView(focus: $focus, ingredient: $ingredient) - } - Button("Add Ingredient") { - let id = UUID().uuidString - cocktail.ingredients.append(Ingredient(id: id)) - - // Workaround delay for the focus to work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focus = id - } + List($ingredients) { $ingredient in + 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) { 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)) - - // Workaround delay for the focus to work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - focus = id - } - } } } 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.id == nil ? "New" : "Update") Cocktail") + .navigationTitle("\(cocktail.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 + 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, newItem: true)) + + // 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!, + name: $0.wrappedName, + quantity: $0.quantity, + unit: $0.unitEnum, + newItem: false + ) + } + + steps = cocktail.wrappedSteps.map { + StepVO(id: $0.uuid!, step: $0.wrappedStep) + } + } + + private func createOrUpdateCocktail() { + if cocktail.uuid == nil { + cocktail.uuid = UUID() + } + + cocktail.name = name + cocktail.flags = withAlcohol ? 1 : 0 + + for ingredient in ingredients { + if let existing = cocktail.wrappedIngredients.first(where: { $0.uuid == ingredient.id }) { + existing.name = ingredient.name + existing.unitEnum = ingredient.unit + 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 + } + } + + for step in steps { + if let existing = cocktail.wrappedSteps.first(where: { $0.uuid == step.id} ) { + existing.step = step.step + } else { + let newStep = Step(context: viewContext) + newStep.uuid = step.id + newStep.cocktail = cocktail + newStep.step = step.step + } + } + + // 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 { + @Binding var ingredient: IngredientVO + var focus: FocusState.Binding - @Binding var ingredient: Ingredient + var addIngredient: (() -> Void) var body: some View { - VStack { - HStack { - TextField("Ingredient", text: $ingredient.name) - .autocapitalization(.words) - .disableAutocorrection(true) - .focused(focus, equals: ingredient.id) + 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) + 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() } } 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) { + } +} diff --git a/Shared/Views/Sidebar.swift b/Shared/Views/Sidebar.swift index c6a55f5..0dba803 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: \BucketList.created, ascending: true)]) + private var buckets: FetchedResults + + @State private var addNewItem = false + @State private var name: String = "" + @State private var itemToAddOrEdit: BucketList? + @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 = BucketList(context: viewContext) + itemToAddOrEdit?.created = Date() + try? viewContext.save() + focus = "name" + } label: { + Label("Add new", systemImage: "plus") + } + } + } .listStyle(.sidebar) } }