Create some image writing utilities and refactor app data flow.
This commit is contained in:
parent
ded8dbda5c
commit
b10502a91d
|
@ -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 = "<group>"; };
|
||||
F3299534239181A900D2D963 /* UIImagePickerWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImagePickerWrapper.swift; sourceTree = "<group>"; };
|
||||
F329953623919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImagePickerWrapper+Coordinator.swift"; sourceTree = "<group>"; };
|
||||
F32995392391EEE800D2D963 /* FilteredImagesState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilteredImagesState.swift; sourceTree = "<group>"; };
|
||||
F329953C2391F1D000D2D963 /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = "<group>"; };
|
||||
F329953E23920AE800D2D963 /* ImageWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWriter.swift; sourceTree = "<group>"; };
|
||||
F32995402393025000D2D963 /* ImageWritingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWritingState.swift; sourceTree = "<group>"; };
|
||||
F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFilteringContainerViewModel.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
F352424F238F3F18009DF1F9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
|
@ -68,6 +78,14 @@
|
|||
path = UIImagePickerWrapper;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F329953B2391F1C300D2D963 /* Sample Data */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F329953C2391F1D000D2D963 /* SampleData.swift */,
|
||||
);
|
||||
path = "Sample Data";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
|
@ -147,6 +168,8 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
F3F566B5238F6A48009E1FB0 /* AppState.swift */,
|
||||
F32995392391EEE800D2D963 /* FilteredImagesState.swift */,
|
||||
F32995402393025000D2D963 /* ImageWritingState.swift */,
|
||||
);
|
||||
path = State;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 */,
|
||||
);
|
||||
|
|
|
@ -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, AppAction> { 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<AppState, AppAction>
|
||||
|
|
|
@ -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<AppAction, Never> {
|
||||
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<FilteredImagesState, FilteredImagesAction> { 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
|
||||
}
|
||||
}
|
|
@ -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<AppAction, Never> {
|
||||
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<ImageWritingState, ImageWritingAction> { state, action in
|
||||
switch action {
|
||||
case let .setWritingError(error):
|
||||
state.writingError = error
|
||||
case .setWritingSuccess:
|
||||
state.writingError = nil
|
||||
}
|
||||
}
|
|
@ -20,6 +20,10 @@
|
|||
<string>1</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>This application would like to be able to save images to your Photos Album.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>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.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
|
|
@ -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
|
|
@ -103,9 +103,6 @@ extension ImageFilteringService {
|
|||
.flatMap { ciImage in
|
||||
self.createCGImage(from: ciImage)
|
||||
}
|
||||
// .map(createCGImage(from:))
|
||||
// .switchToLatest()
|
||||
// .map({ $0 })
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<String, ImageWriter.Error> {
|
||||
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<Bool, Never> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
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<ImageWritingState, Never>? {
|
||||
store?.$state
|
||||
.map(\.imageWriting)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private var hasImageWritingErrorPublisher: AnyPublisher<Bool, Never>? {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ import Combine
|
|||
final class ImageFilteringViewModel: ObservableObject {
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
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<Image, ImageFilteringService.Error> {
|
||||
$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<FilteredImagesState, Never> {
|
||||
store.$state
|
||||
.map(\.filteredImages)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
private var filteredCGImagePublisher: AnyPublisher<CGImage, Never> {
|
||||
filteredImagesStatePublisher
|
||||
.map(\.filteredOutputCGImage)
|
||||
.compactMap { $0 }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
|
||||
|
||||
private var filteredImagePublisher: AnyPublisher<Image?, Never> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue