From b10502a91dea380654271ac4278d355d277735b0 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Sat, 30 Nov 2019 19:53:24 -0600 Subject: [PATCH] Create some image writing utilities and refactor app data flow. --- .../Instafilter.xcodeproj/project.pbxproj | 28 +++++ .../Instafilter/Data/State/AppState.swift | 26 +++++ .../Data/State/FilteredImagesState.swift | 61 ++++++++++ .../Data/State/ImageWritingState.swift | 59 ++++++++++ .../Instafilter/Instafilter/Info.plist | 4 + .../Sample Data/SampleData.swift | 19 ++++ .../Reusables/ImageFilteringService.swift | 3 - .../Instafilter/Reusables/ImageWriter.swift | 89 +++++++++++++++ .../Instafilter/SceneDelegate.swift | 2 + .../Scenes/ImageFilteringContainerView.swift | 81 ++++++++++++-- .../ImageFilteringContainerViewModel.swift | 90 +++++++++++++++ .../Scenes/ImageFilteringView.swift | 21 ++-- .../Scenes/ImageFilteringViewModel.swift | 104 ++++++++---------- 13 files changed, 502 insertions(+), 85 deletions(-) create mode 100644 day-062/Projects/Instafilter/Instafilter/Data/State/FilteredImagesState.swift create mode 100644 day-062/Projects/Instafilter/Instafilter/Data/State/ImageWritingState.swift create mode 100644 day-062/Projects/Instafilter/Instafilter/Preview Content/Sample Data/SampleData.swift create mode 100644 day-062/Projects/Instafilter/Instafilter/Reusables/ImageWriter.swift create mode 100644 day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift diff --git a/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj b/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj index f0ad3c5..4f85667 100644 --- a/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj +++ b/day-062/Projects/Instafilter/Instafilter.xcodeproj/project.pbxproj @@ -12,6 +12,11 @@ F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */; }; F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3299534239181A900D2D963 /* UIImagePickerWrapper.swift */; }; F329953723919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F329953623919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift */; }; + F329953A2391EEE800D2D963 /* FilteredImagesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995392391EEE800D2D963 /* FilteredImagesState.swift */; }; + F329953D2391F1D000D2D963 /* SampleData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F329953C2391F1D000D2D963 /* SampleData.swift */; }; + F329953F23920AE800D2D963 /* ImageWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F329953E23920AE800D2D963 /* ImageWriter.swift */; }; + F32995412393025000D2D963 /* ImageWritingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995402393025000D2D963 /* ImageWritingState.swift */; }; + F32995432393199B00D2D963 /* ImageFilteringContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */; }; F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424D238F3F18009DF1F9 /* AppDelegate.swift */; }; F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424F238F3F18009DF1F9 /* SceneDelegate.swift */; }; F3524254238F3F19009DF1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3524253238F3F19009DF1F9 /* Assets.xcassets */; }; @@ -28,6 +33,11 @@ F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringContainerView.swift; sourceTree = ""; }; F3299534239181A900D2D963 /* UIImagePickerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImagePickerWrapper.swift; sourceTree = ""; }; F329953623919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImagePickerWrapper+Coordinator.swift"; sourceTree = ""; }; + F32995392391EEE800D2D963 /* FilteredImagesState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilteredImagesState.swift; sourceTree = ""; }; + F329953C2391F1D000D2D963 /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = ""; }; + F329953E23920AE800D2D963 /* ImageWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWriter.swift; sourceTree = ""; }; + F32995402393025000D2D963 /* ImageWritingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWritingState.swift; sourceTree = ""; }; + F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringContainerViewModel.swift; sourceTree = ""; }; F352424A238F3F18009DF1F9 /* Instafilter.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Instafilter.app; sourceTree = BUILT_PRODUCTS_DIR; }; F352424D238F3F18009DF1F9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; F352424F238F3F18009DF1F9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -68,6 +78,14 @@ path = UIImagePickerWrapper; sourceTree = ""; }; + F329953B2391F1C300D2D963 /* Sample Data */ = { + isa = PBXGroup; + children = ( + F329953C2391F1D000D2D963 /* SampleData.swift */, + ); + path = "Sample Data"; + sourceTree = ""; + }; F3524241238F3F18009DF1F9 = { isa = PBXGroup; children = ( @@ -103,6 +121,7 @@ F3524255238F3F19009DF1F9 /* Preview Content */ = { isa = PBXGroup; children = ( + F329953B2391F1C300D2D963 /* Sample Data */, F3524256238F3F19009DF1F9 /* Preview Assets.xcassets */, ); path = "Preview Content"; @@ -113,6 +132,7 @@ children = ( F329952D23908BD400D2D963 /* ImageFilteringView.swift */, F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */, + F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */, F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */, ); path = Scenes; @@ -123,6 +143,7 @@ children = ( F32995332391817800D2D963 /* Views */, F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */, + F329953E23920AE800D2D963 /* ImageWriter.swift */, ); path = Reusables; sourceTree = ""; @@ -147,6 +168,8 @@ isa = PBXGroup; children = ( F3F566B5238F6A48009E1FB0 /* AppState.swift */, + F32995392391EEE800D2D963 /* FilteredImagesState.swift */, + F32995402393025000D2D963 /* ImageWritingState.swift */, ); path = State; sourceTree = ""; @@ -228,13 +251,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F329953A2391EEE800D2D963 /* FilteredImagesState.swift in Sources */, F3F566AF238F6556009E1FB0 /* ImageFilteringService.swift in Sources */, F329953723919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift in Sources */, F329952E23908BD400D2D963 /* ImageFilteringView.swift in Sources */, + F32995412393025000D2D963 /* ImageWritingState.swift in Sources */, + F329953F23920AE800D2D963 /* ImageWriter.swift in Sources */, F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */, + F32995432393199B00D2D963 /* ImageFilteringContainerViewModel.swift in Sources */, F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */, F329953023908CB500D2D963 /* ImageFilteringViewModel.swift in Sources */, F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */, + F329953D2391F1D000D2D963 /* SampleData.swift in Sources */, F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */, F3F566B6238F6A48009E1FB0 /* AppState.swift in Sources */, ); diff --git a/day-062/Projects/Instafilter/Instafilter/Data/State/AppState.swift b/day-062/Projects/Instafilter/Instafilter/Data/State/AppState.swift index 48b0222..4a46ae6 100644 --- a/day-062/Projects/Instafilter/Instafilter/Data/State/AppState.swift +++ b/day-062/Projects/Instafilter/Instafilter/Data/State/AppState.swift @@ -10,4 +10,30 @@ import Foundation import CypherPoetSwiftUIKit_DataFlowUtils +struct AppState { + var filteredImages = FilteredImagesState() + var imageWriting = ImageWritingState() +} + +enum AppAction { + case filteredImages(_ filteredImagesAction: FilteredImagesAction) + case imageWriting(_ imageWritingAction: ImageWritingAction) +} + + +//enum AppSideEffect: SideEffect {} + + +// MARK: - Reducer +let appReducer = Reducer { appState, action in + switch action { + case let .filteredImages(action): + filteredImagesReducer.reduce(&appState.filteredImages, action) + case let .imageWriting(action): + imageWritingReducer.reduce(&appState.imageWriting, action) + } +} + + +typealias AppStore = Store diff --git a/day-062/Projects/Instafilter/Instafilter/Data/State/FilteredImagesState.swift b/day-062/Projects/Instafilter/Instafilter/Data/State/FilteredImagesState.swift new file mode 100644 index 0000000..6aaf31b --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Data/State/FilteredImagesState.swift @@ -0,0 +1,61 @@ +// +// FilteredImagesState.swift +// Instafilter +// +// Created by CypherPoet on 11/29/19. +// ✌️ +// + +import Foundation +import UIKit +import Combine +import CypherPoetSwiftUIKit_DataFlowUtils + + +struct FilteredImagesState { + var currentInputImage: UIImage? + var filteredOutputCGImage: CGImage? + var filteringErrorMessage: String? +} + + +enum FilteredImagesSideEffect: SideEffect { + case apply(filter: CIFilter, to: UIImage) + + func mapToAction() -> AnyPublisher { + switch self { + case let .apply(filter, inputImage): + return ImageFilteringService.shared + .apply(filter, to: inputImage) + .map { cgImage in + AppAction.filteredImages(.setFilteredOutput(image: cgImage)) + } + .catch { (error: ImageFilteringService.Error) in + // TODO: Better handling here 🙈 + Just(AppAction.filteredImages(.setFilteringError(message: "Failed"))) + } + .eraseToAnyPublisher() + } + } +} + + +enum FilteredImagesAction { + case setCurrentInput(image: UIImage) + case setFilteredOutput(image: CGImage) + case setFilteringError(message: String) +} + + + +// MARK: - Reducer +let filteredImagesReducer = Reducer { state, action in + switch action { + case .setCurrentInput(let image): + state.currentInputImage = image + case .setFilteredOutput(let image): + state.filteredOutputCGImage = image + case .setFilteringError(let message): + state.filteringErrorMessage = message + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/Data/State/ImageWritingState.swift b/day-062/Projects/Instafilter/Instafilter/Data/State/ImageWritingState.swift new file mode 100644 index 0000000..a1e173b --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Data/State/ImageWritingState.swift @@ -0,0 +1,59 @@ +// +// ImageWritingState.swift +// Instafilter +// +// Created by CypherPoet on 11/30/19. +// ✌️ +// + + +import Foundation +import UIKit +import Combine +import CypherPoetSwiftUIKit_DataFlowUtils + + +struct ImageWritingState { + var hasWritingError: Bool { writingError != nil } + var writingError: ImageWriter.Error? +} + + + +enum ImageWritingSideEffect: SideEffect { + case saveOutput(image: CGImage) + + + func mapToAction() -> AnyPublisher { + switch self { + case let .saveOutput(cgImage): + let imageToSave = UIImage(cgImage: cgImage) + + return ImageWriter + .write(imageToPhotoAlbum: imageToSave) + .map { _ in AppAction.imageWriting(.setWritingSuccess) } + .catch({ (imageWriterError) in + Just(AppAction.imageWriting(.setWritingError(imageWriterError))) + }) + .eraseToAnyPublisher() + } + } +} + + +enum ImageWritingAction { + case setWritingError(ImageWriter.Error) + case setWritingSuccess +} + + + +// MARK: - Reducer +let imageWritingReducer = Reducer { state, action in + switch action { + case let .setWritingError(error): + state.writingError = error + case .setWritingSuccess: + state.writingError = nil + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/Info.plist b/day-062/Projects/Instafilter/Instafilter/Info.plist index 9742bf0..7a1f5cb 100644 --- a/day-062/Projects/Instafilter/Instafilter/Info.plist +++ b/day-062/Projects/Instafilter/Instafilter/Info.plist @@ -20,6 +20,10 @@ 1 LSRequiresIPhoneOS + NSPhotoLibraryAddUsageDescription + This application would like to be able to save images to your Photos Album. + NSPhotoLibraryUsageDescription + This application would like to be able to read and load images from your Photos Album. These can then be used to apply image filtering. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/day-062/Projects/Instafilter/Instafilter/Preview Content/Sample Data/SampleData.swift b/day-062/Projects/Instafilter/Instafilter/Preview Content/Sample Data/SampleData.swift new file mode 100644 index 0000000..94d7c63 --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Preview Content/Sample Data/SampleData.swift @@ -0,0 +1,19 @@ +// +// SampleData.swift +// Instafilter +// +// Created by CypherPoet on 11/29/19. +// ✌️ +// + +#if DEBUG + +import Foundation + + +enum SampleStore { + static let `default` = AppStore(initialState: AppState(), appReducer: appReducer) +} + + +#endif diff --git a/day-062/Projects/Instafilter/Instafilter/Reusables/ImageFilteringService.swift b/day-062/Projects/Instafilter/Instafilter/Reusables/ImageFilteringService.swift index 627d4a8..8c1f039 100644 --- a/day-062/Projects/Instafilter/Instafilter/Reusables/ImageFilteringService.swift +++ b/day-062/Projects/Instafilter/Instafilter/Reusables/ImageFilteringService.swift @@ -103,9 +103,6 @@ extension ImageFilteringService { .flatMap { ciImage in self.createCGImage(from: ciImage) } -// .map(createCGImage(from:)) -// .switchToLatest() -// .map({ $0 }) .eraseToAnyPublisher() } } diff --git a/day-062/Projects/Instafilter/Instafilter/Reusables/ImageWriter.swift b/day-062/Projects/Instafilter/Instafilter/Reusables/ImageWriter.swift new file mode 100644 index 0000000..96f801e --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Reusables/ImageWriter.swift @@ -0,0 +1,89 @@ +// +// ImageWriter.swift +// Instafilter +// +// Created by CypherPoet on 11/29/19. +// ✌️ +// + +import Foundation +import UIKit +import Combine +import Photos + + +enum ImageWriter { + static let defaultQueue = DispatchQueue(label: "Image Writer", qos: .userInitiated) + + enum Error: Swift.Error, Identifiable { + var id: String { self.localizedDescription } + + case failedToCreateAsset + case genericWritingError(Swift.Error) + } + + + static func write( + imageToPhotoAlbum image: UIImage, + on queue: DispatchQueue = defaultQueue + ) -> Future { + Future { resolve in + queue.async { + do { + try PHPhotoLibrary.shared().performChangesAndWait { + let creationRequest = PHAssetCreationRequest.creationRequestForAsset(from: image) + + guard let savedAssetID = creationRequest.placeholderForCreatedAsset?.localIdentifier else { + return resolve(.failure(.failedToCreateAsset)) + } + + resolve(.success(savedAssetID)) + } + } catch { + resolve(.failure(.genericWritingError(error))) + } + } + } + } +} + + +extension ImageWriter { + + static var isAuthorized: Future { + Future { resolve in + PHPhotoLibrary.requestAuthorization({ authStatus in + switch authStatus { + case .authorized: + resolve(.success(true)) + case .notDetermined, + .restricted, + .denied: + resolve(.success(false)) + @unknown default: + resolve(.success(false)) + } + }) + } + } + + +} + + + +// TODO: This might not be needed... sort of just experimenting here 🙂 +extension PHPhotoLibrary { + + static func fetchAuthStatus(then completionHandler: @escaping (Bool) -> Void) { + + // If authozied, callback immediately. + guard authorizationStatus() != .authorized else { + return completionHandler(true) + } + + requestAuthorization { (status) in + completionHandler(status == .authorized) + } + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/SceneDelegate.swift b/day-062/Projects/Instafilter/Instafilter/SceneDelegate.swift index c78edb2..087fa99 100644 --- a/day-062/Projects/Instafilter/Instafilter/SceneDelegate.swift +++ b/day-062/Projects/Instafilter/Instafilter/SceneDelegate.swift @@ -23,10 +23,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) + let store = AppStore(initialState: AppState(), appReducer: appReducer) // Create the SwiftUI view that provides the window contents. let entryView = ImageFilteringContainerView() .accentColor(.pink) + .environmentObject(store) window.rootViewController = UIHostingController(rootView: entryView) diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift index e0b4466..d047ab2 100644 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerView.swift @@ -7,11 +7,17 @@ // import SwiftUI - +import Combine struct ImageFilteringContainerView: View { + @EnvironmentObject var store: AppStore + + @ObservedObject private(set) var viewModel = ImageFilteringContainerViewModel() + + @State private var currentInputImage: UIImage? = nil @State private var isShowingImagePicker = false + @State private var isShowingWritingAuthError = false } @@ -19,35 +25,45 @@ struct ImageFilteringContainerView: View { extension ImageFilteringContainerView { var body: some View { - VStack(spacing: 42.0) { - Spacer() - - imageContent - .layoutPriority(1) - - imagePickerButton - - Spacer() + NavigationView { + VStack(spacing: 42.0) { + Spacer() + + imageContent + .layoutPriority(1) + + imagePickerButton + + Spacer() + } + .navigationBarTitle("📸 Instafilter") + .navigationBarItems(trailing: saveButton) } + .onAppear { self.viewModel.store = self.store } .sheet(isPresented: $isShowingImagePicker) { UIImagePickerWrapper(selectedImage: self.$currentInputImage) } + .alert(isPresented: $isShowingWritingAuthError) { self.imageWritingAuthErrorAlert } + .alert(isPresented: $viewModel.hasImageWritingError) { self.imageWritingErrorAlert } } } // MARK: - Computeds extension ImageFilteringContainerView { + var filteredImagesState: FilteredImagesState { store.state.filteredImages } + var filteredOutputCGImage: CGImage? { filteredImagesState.filteredOutputCGImage } } + // MARK: - View Variables extension ImageFilteringContainerView { private var imageContent: some View { Group { if currentInputImage != nil { - ImageFilteringView(inputImage: currentInputImage!) + ImageFilteringView(inputImage: currentInputImage!, store: store) } else { Text("Select an Image to begin filtering.") .font(.title) @@ -55,7 +71,7 @@ extension ImageFilteringContainerView { } } - + private var imagePickerButton: some View { Button(action: { self.isShowingImagePicker = true @@ -71,11 +87,51 @@ extension ImageFilteringContainerView { .cornerRadius(12) .shadow(color: .gray, radius: 8, x: 0, y: 0) } + + + private var saveButton: some View { + Button(action: { + if self.viewModel.hasImageWritingAuth { + self.saveFilteredImage() + } else { + self.isShowingWritingAuthError = true + } + }) { + Text("Save") + } + .disabled(filteredOutputCGImage == nil) + } + + + private var imageWritingErrorAlert: Alert { + Alert( + title: Text("Failed to save image."), + message: Text(self.viewModel.imageWritingErrorMessage), + dismissButton: .default(Text("OK")) + ) + } + + + private var imageWritingAuthErrorAlert: Alert { + Alert( + title: Text("Unable to save images."), + message: Text(self.viewModel.imageWritingAuthErrorMessage), + dismissButton: .default(Text("OK")) + ) + } } // MARK: - Private Helpers private extension ImageFilteringContainerView { + + func saveFilteredImage() { + guard let filteredCGImage = filteredOutputCGImage else { + preconditionFailure("No output image available for saving") + } + + store.send(ImageWritingSideEffect.saveOutput(image: filteredCGImage)) + } } @@ -84,5 +140,6 @@ struct ImageFilteringContainerView_Previews: PreviewProvider { static var previews: some View { ImageFilteringContainerView() + .environmentObject(SampleStore.default) } } diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift new file mode 100644 index 0000000..9634938 --- /dev/null +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringContainerViewModel.swift @@ -0,0 +1,90 @@ +// +// ImageFilteringContainerViewModel.swift +// Instafilter +// +// Created by CypherPoet on 11/30/19. +// ✌️ +// + + +import SwiftUI +import Combine + + +final class ImageFilteringContainerViewModel: ObservableObject { + private var subscriptions = Set() + + var store: AppStore? { + didSet { + guard store != nil else { return } + self.setupSubscribers() + } + } + + + // MARK: - Published Properties + @Published var hasImageWritingAuth = false + @Published var hasImageWritingError = false +} + + +// MARK: - Publishers +extension ImageFilteringContainerViewModel { + + private var imageWritingStatePublisher: AnyPublisher? { + store?.$state + .map(\.imageWriting) + .eraseToAnyPublisher() + } + + private var hasImageWritingErrorPublisher: AnyPublisher? { + imageWritingStatePublisher? + .map { $0.writingError != nil } + .eraseToAnyPublisher() + } +} + + +// MARK: - Computeds +extension ImageFilteringContainerViewModel { + + var imageWritingAuthErrorMessage: String { + guard !hasImageWritingAuth else { return "" } + + return """ + This app doesn't have permission to write images to your Photos album. + You can grant permission from within the Settings app. + """ + } + + + var imageWritingErrorMessage: String { + guard !hasImageWritingError else { return "" } + + return "Image writing operation failed." + } +} + + +// MARK: - Public Methods +extension ImageFilteringContainerViewModel { +} + + + +// MARK: - Private Helpers +private extension ImageFilteringContainerViewModel { + + func setupSubscribers() { + ImageWriter.isAuthorized + .receive(on: DispatchQueue.main) + .assign(to: \.hasImageWritingAuth, on: self) + .store(in: &subscriptions) + + + hasImageWritingErrorPublisher? + .receive(on: DispatchQueue.main) + .assign(to: \.hasImageWritingError, on: self) + .store(in: &subscriptions) + } +} diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift index 55e7ab7..662c725 100644 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringView.swift @@ -12,10 +12,14 @@ import SwiftUI struct ImageFilteringView: View { @ObservedObject private(set) var viewModel: ImageFilteringViewModel -// @ObservedObject private(set) var viewModel = ImageFilteringViewModel() - - init(inputImage: UIImage) { - self.viewModel = ImageFilteringViewModel(inputImage: inputImage) + init( + inputImage: UIImage, + store: AppStore + ) { + self.viewModel = ImageFilteringViewModel( + inputImage: inputImage, + store: store + ) } } @@ -25,17 +29,15 @@ extension ImageFilteringView { var body: some View { VStack { + // TODO: Use an alert here instead if viewModel.filteringErrorMessage != nil { Text(viewModel.filteringErrorMessage!) } - viewModel.filteredImage? + (viewModel.filteredImage ?? Image(uiImage: viewModel.inputImage)) .resizable() .scaledToFit() } -// .onAppear { -// self.viewModel.inputImage = UIImage(named: "earth-night") -// } } } @@ -60,7 +62,8 @@ struct ImageFilteringView_Previews: PreviewProvider { static var previews: some View { ImageFilteringView( - inputImage: UIImage(named: "earth-night")! + inputImage: UIImage(named: "earth-night")!, + store: SampleStore.default ) } } diff --git a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift index 12b000e..7e084c9 100644 --- a/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift +++ b/day-062/Projects/Instafilter/Instafilter/Scenes/ImageFilteringViewModel.swift @@ -14,7 +14,8 @@ import Combine final class ImageFilteringViewModel: ObservableObject { private var subscriptions = Set() - private var filteringService = ImageFilteringService.shared + private let filteringService = ImageFilteringService.shared + private let store: AppStore @Published var inputImage: UIImage @Published var currentFilter: CIFilter @@ -27,8 +28,12 @@ final class ImageFilteringViewModel: ObservableObject { // MARK: - Init - init(inputImage: UIImage) { + init( + inputImage: UIImage, + store: AppStore + ) { self.inputImage = inputImage + self.store = store self.currentFilter = .sepiaTone() setupSubscribers() @@ -39,34 +44,28 @@ final class ImageFilteringViewModel: ObservableObject { // MARK: - Publishers extension ImageFilteringViewModel { - private var filteredImagePublisher: AnyPublisher { - $inputImage - .print("filteredImagePublisher") - .compactMap { $0 } - .setFailureType(to: ImageFilteringService.Error.self) - .map { inputImage in - self.filteringService.apply(self.currentFilter, to: inputImage) - } - .switchToLatest() - .map { cgImage in - print("filteredImagePublisher mapping cgImage") - return Image(uiImage: UIImage(cgImage: cgImage)) - } -// .catch { error in -// switch error { -// case .cgImage(let message), -// .ciImage(let message), -// .filtering(let message): -// self.filteringErrorMessage = message -// } -// -// return Just(nil).eraseToAnyPublisher() -// } + private var filteredImagesStatePublisher: AnyPublisher { + store.$state + .map(\.filteredImages) .eraseToAnyPublisher() } + + private var filteredCGImagePublisher: AnyPublisher { + filteredImagesStatePublisher + .map(\.filteredOutputCGImage) + .compactMap { $0 } + .eraseToAnyPublisher() + } + - + private var filteredImagePublisher: AnyPublisher { + filteredCGImagePublisher + .map { cgImage in + Image(uiImage: UIImage(cgImage: cgImage)) + } + .eraseToAnyPublisher() + } } @@ -87,46 +86,29 @@ private extension ImageFilteringViewModel { func setupSubscribers() { filteredImagePublisher .receive(on: DispatchQueue.main) -// .handleEvents(receiveCompletion: { completion in -// switch completion { -// case .failure(let error): -// print("filteredImagePublisher error") -// switch error { -// case .cgImage(let message), -// .ciImage(let message), -// .filtering(let message): -// self.filteringErrorMessage = message -// } -// case .finished: -// print("filteredImagePublisher finished") -// } -// }) - .sink( - receiveCompletion: { completion in - switch completion { - case .failure(let error): - print("filteredImagePublisher error") - switch error { - case .cgImage(let message), - .ciImage(let message), - .filtering(let message): - self.filteringErrorMessage = message - } - case .finished: - print("filteredImagePublisher finished") - } - }, - receiveValue: { self.filteredImage = $0 } - ) + .assign(to: \.filteredImage, on: self) .store(in: &subscriptions) - // filteredImagePublisher -// .replaceError(with: nil) -// .compactMap( { $0 }) // .receive(on: DispatchQueue.main) -// .assign(to: \.filteredImage, on: self) +// .sink( +// receiveCompletion: { completion in +// switch completion { +// case .failure(let error): +// print("filteredImagePublisher error") +// switch error { +// case .cgImage(let message), +// .ciImage(let message), +// .filtering(let message): +// self.filteringErrorMessage = message +// } +// case .finished: +// print("filteredImagePublisher finished") +// } +// }, +// receiveValue: { self.filteredImage = $0 } +// ) // .store(in: &subscriptions) } }