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 */; };
|
F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */; };
|
||||||
F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3299534239181A900D2D963 /* UIImagePickerWrapper.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 */; };
|
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 */; };
|
F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424D238F3F18009DF1F9 /* AppDelegate.swift */; };
|
||||||
F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424F238F3F18009DF1F9 /* SceneDelegate.swift */; };
|
F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F352424F238F3F18009DF1F9 /* SceneDelegate.swift */; };
|
||||||
F3524254238F3F19009DF1F9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F3524253238F3F19009DF1F9 /* Assets.xcassets */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
F352424F238F3F18009DF1F9 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
@ -68,6 +78,14 @@
|
||||||
path = UIImagePickerWrapper;
|
path = UIImagePickerWrapper;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F329953B2391F1C300D2D963 /* Sample Data */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F329953C2391F1D000D2D963 /* SampleData.swift */,
|
||||||
|
);
|
||||||
|
path = "Sample Data";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F3524241238F3F18009DF1F9 = {
|
F3524241238F3F18009DF1F9 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -103,6 +121,7 @@
|
||||||
F3524255238F3F19009DF1F9 /* Preview Content */ = {
|
F3524255238F3F19009DF1F9 /* Preview Content */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
F329953B2391F1C300D2D963 /* Sample Data */,
|
||||||
F3524256238F3F19009DF1F9 /* Preview Assets.xcassets */,
|
F3524256238F3F19009DF1F9 /* Preview Assets.xcassets */,
|
||||||
);
|
);
|
||||||
path = "Preview Content";
|
path = "Preview Content";
|
||||||
|
@ -113,6 +132,7 @@
|
||||||
children = (
|
children = (
|
||||||
F329952D23908BD400D2D963 /* ImageFilteringView.swift */,
|
F329952D23908BD400D2D963 /* ImageFilteringView.swift */,
|
||||||
F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */,
|
F32995312390C29100D2D963 /* ImageFilteringContainerView.swift */,
|
||||||
|
F32995422393199B00D2D963 /* ImageFilteringContainerViewModel.swift */,
|
||||||
F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */,
|
F329952F23908CB500D2D963 /* ImageFilteringViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = Scenes;
|
path = Scenes;
|
||||||
|
@ -123,6 +143,7 @@
|
||||||
children = (
|
children = (
|
||||||
F32995332391817800D2D963 /* Views */,
|
F32995332391817800D2D963 /* Views */,
|
||||||
F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */,
|
F3F566AE238F6556009E1FB0 /* ImageFilteringService.swift */,
|
||||||
|
F329953E23920AE800D2D963 /* ImageWriter.swift */,
|
||||||
);
|
);
|
||||||
path = Reusables;
|
path = Reusables;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -147,6 +168,8 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
F3F566B5238F6A48009E1FB0 /* AppState.swift */,
|
F3F566B5238F6A48009E1FB0 /* AppState.swift */,
|
||||||
|
F32995392391EEE800D2D963 /* FilteredImagesState.swift */,
|
||||||
|
F32995402393025000D2D963 /* ImageWritingState.swift */,
|
||||||
);
|
);
|
||||||
path = State;
|
path = State;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -228,13 +251,18 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
F329953A2391EEE800D2D963 /* FilteredImagesState.swift in Sources */,
|
||||||
F3F566AF238F6556009E1FB0 /* ImageFilteringService.swift in Sources */,
|
F3F566AF238F6556009E1FB0 /* ImageFilteringService.swift in Sources */,
|
||||||
F329953723919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift in Sources */,
|
F329953723919B6700D2D963 /* UIImagePickerWrapper+Coordinator.swift in Sources */,
|
||||||
F329952E23908BD400D2D963 /* ImageFilteringView.swift in Sources */,
|
F329952E23908BD400D2D963 /* ImageFilteringView.swift in Sources */,
|
||||||
|
F32995412393025000D2D963 /* ImageWritingState.swift in Sources */,
|
||||||
|
F329953F23920AE800D2D963 /* ImageWriter.swift in Sources */,
|
||||||
F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */,
|
F32995322390C29100D2D963 /* ImageFilteringContainerView.swift in Sources */,
|
||||||
|
F32995432393199B00D2D963 /* ImageFilteringContainerViewModel.swift in Sources */,
|
||||||
F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */,
|
F352424E238F3F18009DF1F9 /* AppDelegate.swift in Sources */,
|
||||||
F329953023908CB500D2D963 /* ImageFilteringViewModel.swift in Sources */,
|
F329953023908CB500D2D963 /* ImageFilteringViewModel.swift in Sources */,
|
||||||
F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */,
|
F3299535239181A900D2D963 /* UIImagePickerWrapper.swift in Sources */,
|
||||||
|
F329953D2391F1D000D2D963 /* SampleData.swift in Sources */,
|
||||||
F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */,
|
F3524250238F3F18009DF1F9 /* SceneDelegate.swift in Sources */,
|
||||||
F3F566B6238F6A48009E1FB0 /* AppState.swift in Sources */,
|
F3F566B6238F6A48009E1FB0 /* AppState.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,4 +10,30 @@ import Foundation
|
||||||
import CypherPoetSwiftUIKit_DataFlowUtils
|
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>
|
<string>1</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<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>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<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
|
.flatMap { ciImage in
|
||||||
self.createCGImage(from: ciImage)
|
self.createCGImage(from: ciImage)
|
||||||
}
|
}
|
||||||
// .map(createCGImage(from:))
|
|
||||||
// .switchToLatest()
|
|
||||||
// .map({ $0 })
|
|
||||||
.eraseToAnyPublisher()
|
.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.
|
// Use a UIHostingController as window root view controller.
|
||||||
if let windowScene = scene as? UIWindowScene {
|
if let windowScene = scene as? UIWindowScene {
|
||||||
let window = UIWindow(windowScene: windowScene)
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
let store = AppStore(initialState: AppState(), appReducer: appReducer)
|
||||||
|
|
||||||
// Create the SwiftUI view that provides the window contents.
|
// Create the SwiftUI view that provides the window contents.
|
||||||
let entryView = ImageFilteringContainerView()
|
let entryView = ImageFilteringContainerView()
|
||||||
.accentColor(.pink)
|
.accentColor(.pink)
|
||||||
|
.environmentObject(store)
|
||||||
|
|
||||||
window.rootViewController = UIHostingController(rootView: entryView)
|
window.rootViewController = UIHostingController(rootView: entryView)
|
||||||
|
|
||||||
|
|
|
@ -7,11 +7,17 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
struct ImageFilteringContainerView: View {
|
struct ImageFilteringContainerView: View {
|
||||||
|
@EnvironmentObject var store: AppStore
|
||||||
|
|
||||||
|
@ObservedObject private(set) var viewModel = ImageFilteringContainerViewModel()
|
||||||
|
|
||||||
|
|
||||||
@State private var currentInputImage: UIImage? = nil
|
@State private var currentInputImage: UIImage? = nil
|
||||||
@State private var isShowingImagePicker = false
|
@State private var isShowingImagePicker = false
|
||||||
|
@State private var isShowingWritingAuthError = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,35 +25,45 @@ struct ImageFilteringContainerView: View {
|
||||||
extension ImageFilteringContainerView {
|
extension ImageFilteringContainerView {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 42.0) {
|
NavigationView {
|
||||||
Spacer()
|
VStack(spacing: 42.0) {
|
||||||
|
Spacer()
|
||||||
imageContent
|
|
||||||
.layoutPriority(1)
|
imageContent
|
||||||
|
.layoutPriority(1)
|
||||||
imagePickerButton
|
|
||||||
|
imagePickerButton
|
||||||
Spacer()
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.navigationBarTitle("📸 Instafilter")
|
||||||
|
.navigationBarItems(trailing: saveButton)
|
||||||
}
|
}
|
||||||
|
.onAppear { self.viewModel.store = self.store }
|
||||||
.sheet(isPresented: $isShowingImagePicker) {
|
.sheet(isPresented: $isShowingImagePicker) {
|
||||||
UIImagePickerWrapper(selectedImage: self.$currentInputImage)
|
UIImagePickerWrapper(selectedImage: self.$currentInputImage)
|
||||||
}
|
}
|
||||||
|
.alert(isPresented: $isShowingWritingAuthError) { self.imageWritingAuthErrorAlert }
|
||||||
|
.alert(isPresented: $viewModel.hasImageWritingError) { self.imageWritingErrorAlert }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Computeds
|
// MARK: - Computeds
|
||||||
extension ImageFilteringContainerView {
|
extension ImageFilteringContainerView {
|
||||||
|
var filteredImagesState: FilteredImagesState { store.state.filteredImages }
|
||||||
|
var filteredOutputCGImage: CGImage? { filteredImagesState.filteredOutputCGImage }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - View Variables
|
// MARK: - View Variables
|
||||||
extension ImageFilteringContainerView {
|
extension ImageFilteringContainerView {
|
||||||
|
|
||||||
private var imageContent: some View {
|
private var imageContent: some View {
|
||||||
Group {
|
Group {
|
||||||
if currentInputImage != nil {
|
if currentInputImage != nil {
|
||||||
ImageFilteringView(inputImage: currentInputImage!)
|
ImageFilteringView(inputImage: currentInputImage!, store: store)
|
||||||
} else {
|
} else {
|
||||||
Text("Select an Image to begin filtering.")
|
Text("Select an Image to begin filtering.")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
|
@ -55,7 +71,7 @@ extension ImageFilteringContainerView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private var imagePickerButton: some View {
|
private var imagePickerButton: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
self.isShowingImagePicker = true
|
self.isShowingImagePicker = true
|
||||||
|
@ -71,11 +87,51 @@ extension ImageFilteringContainerView {
|
||||||
.cornerRadius(12)
|
.cornerRadius(12)
|
||||||
.shadow(color: .gray, radius: 8, x: 0, y: 0)
|
.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
|
// MARK: - Private Helpers
|
||||||
private extension ImageFilteringContainerView {
|
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 {
|
static var previews: some View {
|
||||||
ImageFilteringContainerView()
|
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 {
|
struct ImageFilteringView: View {
|
||||||
@ObservedObject private(set) var viewModel: ImageFilteringViewModel
|
@ObservedObject private(set) var viewModel: ImageFilteringViewModel
|
||||||
|
|
||||||
// @ObservedObject private(set) var viewModel = ImageFilteringViewModel()
|
init(
|
||||||
|
inputImage: UIImage,
|
||||||
init(inputImage: UIImage) {
|
store: AppStore
|
||||||
self.viewModel = ImageFilteringViewModel(inputImage: inputImage)
|
) {
|
||||||
|
self.viewModel = ImageFilteringViewModel(
|
||||||
|
inputImage: inputImage,
|
||||||
|
store: store
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,17 +29,15 @@ extension ImageFilteringView {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
// TODO: Use an alert here instead
|
||||||
if viewModel.filteringErrorMessage != nil {
|
if viewModel.filteringErrorMessage != nil {
|
||||||
Text(viewModel.filteringErrorMessage!)
|
Text(viewModel.filteringErrorMessage!)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.filteredImage?
|
(viewModel.filteredImage ?? Image(uiImage: viewModel.inputImage))
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
}
|
}
|
||||||
// .onAppear {
|
|
||||||
// self.viewModel.inputImage = UIImage(named: "earth-night")
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +62,8 @@ struct ImageFilteringView_Previews: PreviewProvider {
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
ImageFilteringView(
|
ImageFilteringView(
|
||||||
inputImage: UIImage(named: "earth-night")!
|
inputImage: UIImage(named: "earth-night")!,
|
||||||
|
store: SampleStore.default
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,8 @@ import Combine
|
||||||
final class ImageFilteringViewModel: ObservableObject {
|
final class ImageFilteringViewModel: ObservableObject {
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
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 inputImage: UIImage
|
||||||
@Published var currentFilter: CIFilter
|
@Published var currentFilter: CIFilter
|
||||||
|
@ -27,8 +28,12 @@ final class ImageFilteringViewModel: ObservableObject {
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
init(inputImage: UIImage) {
|
init(
|
||||||
|
inputImage: UIImage,
|
||||||
|
store: AppStore
|
||||||
|
) {
|
||||||
self.inputImage = inputImage
|
self.inputImage = inputImage
|
||||||
|
self.store = store
|
||||||
self.currentFilter = .sepiaTone()
|
self.currentFilter = .sepiaTone()
|
||||||
|
|
||||||
setupSubscribers()
|
setupSubscribers()
|
||||||
|
@ -39,34 +44,28 @@ final class ImageFilteringViewModel: ObservableObject {
|
||||||
// MARK: - Publishers
|
// MARK: - Publishers
|
||||||
extension ImageFilteringViewModel {
|
extension ImageFilteringViewModel {
|
||||||
|
|
||||||
private var filteredImagePublisher: AnyPublisher<Image, ImageFilteringService.Error> {
|
private var filteredImagesStatePublisher: AnyPublisher<FilteredImagesState, Never> {
|
||||||
$inputImage
|
store.$state
|
||||||
.print("filteredImagePublisher")
|
.map(\.filteredImages)
|
||||||
.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()
|
|
||||||
// }
|
|
||||||
.eraseToAnyPublisher()
|
.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() {
|
func setupSubscribers() {
|
||||||
filteredImagePublisher
|
filteredImagePublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
// .handleEvents(receiveCompletion: { completion in
|
.assign(to: \.filteredImage, on: self)
|
||||||
// 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 }
|
|
||||||
)
|
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// filteredImagePublisher
|
// filteredImagePublisher
|
||||||
// .replaceError(with: nil)
|
|
||||||
// .compactMap( { $0 })
|
|
||||||
// .receive(on: DispatchQueue.main)
|
// .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)
|
// .store(in: &subscriptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue