From f08035c6d28b9b48c5b31816142c1a4638918082 Mon Sep 17 00:00:00 2001 From: morgan smith Date: Thu, 3 Sep 2020 11:41:40 -0400 Subject: [PATCH 1/7] created/finished ImageFilterEditor --- .../project.pbxproj | 389 +++++++++++ .../ImageFilterEditor/AppDelegate.swift | 37 ++ .../AppIcon.appiconset/Contents.json | 98 +++ .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../Base.lproj/Main.storyboard | 628 ++++++++++++++++++ .../BlurViewController.swift | 107 +++ .../ColorInvertViewController.swift | 89 +++ .../ColorViewController.swift | 115 ++++ .../DistortionViewController.swift | 116 ++++ .../SharpenViewController.swift | 93 +++ .../Helper/Image+Ratio.swift | 16 + .../Helper/UIImage+Scaling.swift | 38 ++ .../UIViewController+InformationalAlert.swift | 22 + .../ImagePostViewController.swift | 132 ++++ .../ImageFilterEditor/Info.plist | 68 ++ .../ImageFilterEditor/SceneDelegate.swift | 53 ++ .../ImagePostViewController.swift | 2 +- 18 files changed, 2033 insertions(+), 1 deletion(-) create mode 100644 ImageFilterEditor/ImageFilterEditor.xcodeproj/project.pbxproj create mode 100644 ImageFilterEditor/ImageFilterEditor/AppDelegate.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ImageFilterEditor/ImageFilterEditor/Assets.xcassets/Contents.json create mode 100644 ImageFilterEditor/ImageFilterEditor/Base.lproj/LaunchScreen.storyboard create mode 100644 ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard create mode 100644 ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorInvertViewController.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorViewController.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Filter View Controllers/DistortionViewController.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Filter View Controllers/SharpenViewController.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Helper/Image+Ratio.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Helper/UIImage+Scaling.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Helper/UIViewController+InformationalAlert.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/ImagePostViewController.swift create mode 100644 ImageFilterEditor/ImageFilterEditor/Info.plist create mode 100644 ImageFilterEditor/ImageFilterEditor/SceneDelegate.swift diff --git a/ImageFilterEditor/ImageFilterEditor.xcodeproj/project.pbxproj b/ImageFilterEditor/ImageFilterEditor.xcodeproj/project.pbxproj new file mode 100644 index 00000000..bcf11861 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor.xcodeproj/project.pbxproj @@ -0,0 +1,389 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 02C7B54A2500114200C4A310 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5492500114200C4A310 /* AppDelegate.swift */; }; + 02C7B54C2500114200C4A310 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B54B2500114200C4A310 /* SceneDelegate.swift */; }; + 02C7B5512500114200C4A310 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 02C7B54F2500114200C4A310 /* Main.storyboard */; }; + 02C7B5532500114A00C4A310 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02C7B5522500114A00C4A310 /* Assets.xcassets */; }; + 02C7B5562500114A00C4A310 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 02C7B5542500114A00C4A310 /* LaunchScreen.storyboard */; }; + 02C7B55E2500132300C4A310 /* ImagePostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B55D2500132300C4A310 /* ImagePostViewController.swift */; }; + 02C7B56025001AF000C4A310 /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B55F25001AF000C4A310 /* UIImage+Scaling.swift */; }; + 02C7B56225001CD900C4A310 /* Image+Ratio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B56125001CD900C4A310 /* Image+Ratio.swift */; }; + 02C7B56425001DDB00C4A310 /* UIViewController+InformationalAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B56325001DDB00C4A310 /* UIViewController+InformationalAlert.swift */; }; + 02C7B56625001E4300C4A310 /* BlurViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B56525001E4300C4A310 /* BlurViewController.swift */; }; + 02C7B56A25001E9E00C4A310 /* ColorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B56925001E9E00C4A310 /* ColorViewController.swift */; }; + 02C7B56C25001EBE00C4A310 /* DistortionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B56B25001EBE00C4A310 /* DistortionViewController.swift */; }; + 02C7B57025001EE600C4A310 /* SharpenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B56F25001EE600C4A310 /* SharpenViewController.swift */; }; + 02C7B572250140EB00C4A310 /* ColorInvertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B571250140EB00C4A310 /* ColorInvertViewController.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 02C7B5462500114200C4A310 /* ImageFilterEditor.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ImageFilterEditor.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 02C7B5492500114200C4A310 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 02C7B54B2500114200C4A310 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 02C7B5502500114200C4A310 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 02C7B5522500114A00C4A310 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 02C7B5552500114A00C4A310 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 02C7B5572500114A00C4A310 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 02C7B55D2500132300C4A310 /* ImagePostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePostViewController.swift; sourceTree = ""; }; + 02C7B55F25001AF000C4A310 /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; + 02C7B56125001CD900C4A310 /* Image+Ratio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Image+Ratio.swift"; sourceTree = ""; }; + 02C7B56325001DDB00C4A310 /* UIViewController+InformationalAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+InformationalAlert.swift"; sourceTree = ""; }; + 02C7B56525001E4300C4A310 /* BlurViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurViewController.swift; sourceTree = ""; }; + 02C7B56925001E9E00C4A310 /* ColorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorViewController.swift; sourceTree = ""; }; + 02C7B56B25001EBE00C4A310 /* DistortionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DistortionViewController.swift; sourceTree = ""; }; + 02C7B56F25001EE600C4A310 /* SharpenViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharpenViewController.swift; sourceTree = ""; }; + 02C7B571250140EB00C4A310 /* ColorInvertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInvertViewController.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 02C7B5432500114200C4A310 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 02C7B53D2500114200C4A310 = { + isa = PBXGroup; + children = ( + 02C7B5482500114200C4A310 /* ImageFilterEditor */, + 02C7B5472500114200C4A310 /* Products */, + ); + sourceTree = ""; + }; + 02C7B5472500114200C4A310 /* Products */ = { + isa = PBXGroup; + children = ( + 02C7B5462500114200C4A310 /* ImageFilterEditor.app */, + ); + name = Products; + sourceTree = ""; + }; + 02C7B5482500114200C4A310 /* ImageFilterEditor */ = { + isa = PBXGroup; + children = ( + 02C7B55D2500132300C4A310 /* ImagePostViewController.swift */, + 02C7B56825001E5A00C4A310 /* Filter View Controllers */, + 02C7B56725001E4B00C4A310 /* Helper */, + 02C7B5492500114200C4A310 /* AppDelegate.swift */, + 02C7B54B2500114200C4A310 /* SceneDelegate.swift */, + 02C7B54F2500114200C4A310 /* Main.storyboard */, + 02C7B5522500114A00C4A310 /* Assets.xcassets */, + 02C7B5542500114A00C4A310 /* LaunchScreen.storyboard */, + 02C7B5572500114A00C4A310 /* Info.plist */, + ); + path = ImageFilterEditor; + sourceTree = ""; + }; + 02C7B56725001E4B00C4A310 /* Helper */ = { + isa = PBXGroup; + children = ( + 02C7B55F25001AF000C4A310 /* UIImage+Scaling.swift */, + 02C7B56125001CD900C4A310 /* Image+Ratio.swift */, + 02C7B56325001DDB00C4A310 /* UIViewController+InformationalAlert.swift */, + ); + path = Helper; + sourceTree = ""; + }; + 02C7B56825001E5A00C4A310 /* Filter View Controllers */ = { + isa = PBXGroup; + children = ( + 02C7B56525001E4300C4A310 /* BlurViewController.swift */, + 02C7B56925001E9E00C4A310 /* ColorViewController.swift */, + 02C7B56B25001EBE00C4A310 /* DistortionViewController.swift */, + 02C7B56F25001EE600C4A310 /* SharpenViewController.swift */, + 02C7B571250140EB00C4A310 /* ColorInvertViewController.swift */, + ); + path = "Filter View Controllers"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 02C7B5452500114200C4A310 /* ImageFilterEditor */ = { + isa = PBXNativeTarget; + buildConfigurationList = 02C7B55A2500114A00C4A310 /* Build configuration list for PBXNativeTarget "ImageFilterEditor" */; + buildPhases = ( + 02C7B5422500114200C4A310 /* Sources */, + 02C7B5432500114200C4A310 /* Frameworks */, + 02C7B5442500114200C4A310 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ImageFilterEditor; + productName = ImageFilterEditor; + productReference = 02C7B5462500114200C4A310 /* ImageFilterEditor.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 02C7B53E2500114200C4A310 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1150; + LastUpgradeCheck = 1150; + ORGANIZATIONNAME = "Morgan Smith"; + TargetAttributes = { + 02C7B5452500114200C4A310 = { + CreatedOnToolsVersion = 11.5; + }; + }; + }; + buildConfigurationList = 02C7B5412500114200C4A310 /* Build configuration list for PBXProject "ImageFilterEditor" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 02C7B53D2500114200C4A310; + productRefGroup = 02C7B5472500114200C4A310 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 02C7B5452500114200C4A310 /* ImageFilterEditor */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 02C7B5442500114200C4A310 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02C7B5562500114A00C4A310 /* LaunchScreen.storyboard in Resources */, + 02C7B5532500114A00C4A310 /* Assets.xcassets in Resources */, + 02C7B5512500114200C4A310 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 02C7B5422500114200C4A310 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02C7B56625001E4300C4A310 /* BlurViewController.swift in Sources */, + 02C7B572250140EB00C4A310 /* ColorInvertViewController.swift in Sources */, + 02C7B54A2500114200C4A310 /* AppDelegate.swift in Sources */, + 02C7B56A25001E9E00C4A310 /* ColorViewController.swift in Sources */, + 02C7B56025001AF000C4A310 /* UIImage+Scaling.swift in Sources */, + 02C7B55E2500132300C4A310 /* ImagePostViewController.swift in Sources */, + 02C7B56C25001EBE00C4A310 /* DistortionViewController.swift in Sources */, + 02C7B54C2500114200C4A310 /* SceneDelegate.swift in Sources */, + 02C7B57025001EE600C4A310 /* SharpenViewController.swift in Sources */, + 02C7B56225001CD900C4A310 /* Image+Ratio.swift in Sources */, + 02C7B56425001DDB00C4A310 /* UIViewController+InformationalAlert.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 02C7B54F2500114200C4A310 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 02C7B5502500114200C4A310 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 02C7B5542500114A00C4A310 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 02C7B5552500114A00C4A310 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 02C7B5582500114A00C4A310 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 02C7B5592500114A00C4A310 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 02C7B55B2500114A00C4A310 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ImageFilterEditor/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.morgansmith.ImageFilterEditor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 02C7B55C2500114A00C4A310 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = ImageFilterEditor/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.morgansmith.ImageFilterEditor; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 02C7B5412500114200C4A310 /* Build configuration list for PBXProject "ImageFilterEditor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02C7B5582500114A00C4A310 /* Debug */, + 02C7B5592500114A00C4A310 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 02C7B55A2500114A00C4A310 /* Build configuration list for PBXNativeTarget "ImageFilterEditor" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02C7B55B2500114A00C4A310 /* Debug */, + 02C7B55C2500114A00C4A310 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 02C7B53E2500114200C4A310 /* Project object */; +} diff --git a/ImageFilterEditor/ImageFilterEditor/AppDelegate.swift b/ImageFilterEditor/ImageFilterEditor/AppDelegate.swift new file mode 100644 index 00000000..e47fb9bc --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/AppDelegate.swift @@ -0,0 +1,37 @@ +// +// AppDelegate.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/ImageFilterEditor/ImageFilterEditor/Assets.xcassets/AppIcon.appiconset/Contents.json b/ImageFilterEditor/ImageFilterEditor/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ImageFilterEditor/ImageFilterEditor/Assets.xcassets/Contents.json b/ImageFilterEditor/ImageFilterEditor/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ImageFilterEditor/ImageFilterEditor/Base.lproj/LaunchScreen.storyboard b/ImageFilterEditor/ImageFilterEditor/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard b/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard new file mode 100644 index 00000000..5dd4b030 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard @@ -0,0 +1,628 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift new file mode 100644 index 00000000..7836f65f --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift @@ -0,0 +1,107 @@ +// +// BlurViewController.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +import CoreImage.CIFilterBuiltins + +class BlurViewController: UIViewController { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var blurSlider: UISlider! + + private let context = CIContext(options: nil) + let filter = CIFilter(name: "CIGaussianBlur")! + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + setUpBlurSlider() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + + @IBAction func blurChanged(_ sender: UISlider) { + updateImage() + } + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(blurSlider.value, forKey: "inputRadius") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + func setUpBlurSlider() { + if let inputRadiusDictionary = filter.attributes["inputRadius"] as? [String: Any], + let sliderMin = inputRadiusDictionary[kCIAttributeSliderMin] as? Float, + let sliderMax = inputRadiusDictionary[kCIAttributeSliderMax] as? Float, + let identity = inputRadiusDictionary[kCIAttributeIdentity] as? Float { + + blurSlider.minimumValue = sliderMin + blurSlider.maximumValue = sliderMax + blurSlider.value = identity + } + } + + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveBlur" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + } + } + } + +} diff --git a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorInvertViewController.swift b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorInvertViewController.swift new file mode 100644 index 00000000..6549e1ed --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorInvertViewController.swift @@ -0,0 +1,89 @@ +// +// ColorInvertViewController.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +import CoreImage.CIFilterBuiltins + +class ColorInvertViewController: UIViewController { + @IBOutlet weak var imageView: UIImageView! + private let context = CIContext(options: nil) + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + @IBAction func invertTapped(_ sender: Any) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let filter = CIFilter(name: "CIColorInvert")! + filter.setValue(ciImage, forKey: "inputImage") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveColorInvert" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + } + } + } + + } + diff --git a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorViewController.swift b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorViewController.swift new file mode 100644 index 00000000..f986e867 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/ColorViewController.swift @@ -0,0 +1,115 @@ +// +// ColorViewController.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +import CoreImage.CIFilterBuiltins + +class ColorViewController: UIViewController { + + @IBOutlet weak var brightnessSlider: UISlider! + @IBOutlet weak var contrastSlider: UISlider! + @IBOutlet weak var saturationSlider: UISlider! + @IBOutlet weak var imageView: UIImageView! + + private let context = CIContext(options: nil) + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func brightnessChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func contrastChanged(_ sender: Any) { + updateImage() + } + + @IBAction func saturationChanged(_ sender: Any) { + updateImage() + } + + + @IBAction func cancelTapped(_ sender: Any) { + navigationController?.popViewController(animated: true) + } + + @IBAction func saveTapped(_ sender: Any) { + + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + + let filter = CIFilter(name: "CIColorControls")! + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(saturationSlider.value, forKey: "inputSaturation") + filter.setValue(brightnessSlider.value, forKey: "inputBrightness") + filter.setValue(contrastSlider.value, forKey: "inputContrast") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + + + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveColor" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + } + } + } + + +} diff --git a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/DistortionViewController.swift b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/DistortionViewController.swift new file mode 100644 index 00000000..d7c42cfc --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/DistortionViewController.swift @@ -0,0 +1,116 @@ +// +// DistortionViewController.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +import CoreImage.CIFilterBuiltins + +class DistortionViewController: UIViewController { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var radiusSlider: UISlider! + @IBOutlet weak var angleSlider: UISlider! + @IBOutlet weak var xCenterSlider: UISlider! + @IBOutlet weak var yCenterSlider: UISlider! + + + private let context = CIContext(options: nil) + let filter = CIFilter(name: "CIVortexDistortion")! + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + + @IBAction func radiusChanged(_ sender: UISlider) { + updateImage() + } + @IBAction func angleChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func xCenterChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func yCenterChanged(_ sender: UISlider) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let center = CIVector(x: CGFloat(xCenterSlider.value), y: CGFloat(yCenterSlider.value)) + + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(radiusSlider.value, forKey: "inputRadius") + filter.setValue(angleSlider.value, forKey: "inputAngle") + filter.setValue(center, forKey: "inputCenter") + + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveDistortion" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + } + } + } + +} + diff --git a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/SharpenViewController.swift b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/SharpenViewController.swift new file mode 100644 index 00000000..5e546846 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/SharpenViewController.swift @@ -0,0 +1,93 @@ +// +// SharpenViewController.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +import CoreImage.CIFilterBuiltins + +class SharpenViewController: UIViewController { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var sharpenSlider: UISlider! + + private let context = CIContext(options: nil) + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + @IBAction func sharpenTapped(_ sender: Any) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let filter = CIFilter(name: "CISharpenLuminance")! + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(sharpenSlider.value, forKey: "inputSharpness") + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveSharpen" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + } + } + } + +} + + diff --git a/ImageFilterEditor/ImageFilterEditor/Helper/Image+Ratio.swift b/ImageFilterEditor/ImageFilterEditor/Helper/Image+Ratio.swift new file mode 100644 index 00000000..a80e2b92 --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Helper/Image+Ratio.swift @@ -0,0 +1,16 @@ +// +// Image+Ratio.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + + +import UIKit + +extension UIImage { + var ratio: CGFloat { + return size.height / size.width + } +} diff --git a/ImageFilterEditor/ImageFilterEditor/Helper/UIImage+Scaling.swift b/ImageFilterEditor/ImageFilterEditor/Helper/UIImage+Scaling.swift new file mode 100644 index 00000000..d60f0c4a --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Helper/UIImage+Scaling.swift @@ -0,0 +1,38 @@ +// +// UIImage+Scaling.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// +import UIKit + +extension UIImage { + + /// Resize the image to a max dimension from size parameter + func imageByScaling(toSize size: CGSize) -> UIImage? { + guard size.width > 0 && size.height > 0 else { return nil } + + let originalAspectRatio = self.size.width/self.size.height + var correctedSize = size + + if correctedSize.width > correctedSize.width*originalAspectRatio { + correctedSize.width = correctedSize.width*originalAspectRatio + } else { + correctedSize.height = correctedSize.height/originalAspectRatio + } + + return UIGraphicsImageRenderer(size: correctedSize, format: imageRendererFormat).image { context in + draw(in: CGRect(origin: .zero, size: correctedSize)) + } + } + + /// Renders the image if the pixel data was rotated due to orientation of camera + var flattened: UIImage { + if imageOrientation == .up { return self } + return UIGraphicsImageRenderer(size: size, format: imageRendererFormat).image { context in + draw(at: .zero) + } + } +} + diff --git a/ImageFilterEditor/ImageFilterEditor/Helper/UIViewController+InformationalAlert.swift b/ImageFilterEditor/ImageFilterEditor/Helper/UIViewController+InformationalAlert.swift new file mode 100644 index 00000000..f39803cf --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Helper/UIViewController+InformationalAlert.swift @@ -0,0 +1,22 @@ +// +// UIViewController+InformationalAlert.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit + +extension UIViewController { + + func presentInformationalAlertController(title: String?, message: String?, dismissActionCompletion: ((UIAlertAction) -> Void)? = nil, completion: (() -> Void)? = nil) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let dismissAction = UIAlertAction(title: "Dismiss", style: .cancel, handler: dismissActionCompletion) + + alertController.addAction(dismissAction) + + present(alertController, animated: true, completion: completion) + } +} + diff --git a/ImageFilterEditor/ImageFilterEditor/ImagePostViewController.swift b/ImageFilterEditor/ImageFilterEditor/ImagePostViewController.swift new file mode 100644 index 00000000..383bc73d --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/ImagePostViewController.swift @@ -0,0 +1,132 @@ +// +// ImagePostViewController.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +import CoreImage.CIFilterBuiltins + +class ImagePostViewController: UIViewController { + + var imageData: Data? + var filteredImage: UIImage? + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleTextField: UITextField! + @IBOutlet weak var chooseImageButton: UIBarButtonItem! + @IBOutlet weak var imageHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var postButton: UIBarButtonItem! + + static var sharedPhoto = UIImage() + + override func viewDidLoad() { + super.viewDidLoad() + if let filtered = filteredImage { + imageView.image = filtered + } else { + updateViews() + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(true) + if let filtered = filteredImage { + imageView.image = filtered + } else { + updateViews() + } + } + + func updateViews() { + guard let imageData = imageData, + let image = UIImage(data: imageData) else { + title = "New Post" + return + } + title = "New Title" + imageView.image = image + } + + + @IBAction func filterPhoto(_ sender: Any) { + ImagePostViewController.sharedPhoto = imageView.image ?? UIImage(systemName: "photo")! + } + + + @IBAction func chooseImage(_ sender: Any) { + + let authorizationStatus = PHPhotoLibrary.authorizationStatus() + + switch authorizationStatus { + case .authorized: + presentImagePickerController() + case .notDetermined: + + PHPhotoLibrary.requestAuthorization { (status) in + + guard status == .authorized else { + NSLog("User did not authorize access to the photo library") + self.presentInformationalAlertController(title: "Error", message: "In order to access the photo library, you must allow this application access to it.") + return + } + + self.presentImagePickerController() + } + + case .denied: + self.presentInformationalAlertController(title: "Error", message: "In order to access the photo library, you must allow this application access to it.") + case .restricted: + self.presentInformationalAlertController(title: "Error", message: "Unable to access the photo library. Your device's restrictions do not allow access.") + + @unknown default: + print("FatalError") + } + presentImagePickerController() + + } + + private func presentImagePickerController() { + + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { + presentInformationalAlertController(title: "Error", message: "The photo library is unavailable") + return + } + + let imagePicker = UIImagePickerController() + + imagePicker.delegate = self + + imagePicker.sourceType = .photoLibrary + + present(imagePicker, animated: true, completion: nil) + } + + + + // MARK: - Navigation + +} + +extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + + + picker.dismiss(animated: true, completion: nil) + + guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } + + imageView.image = image + + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true, completion: nil) + } +} + diff --git a/ImageFilterEditor/ImageFilterEditor/Info.plist b/ImageFilterEditor/ImageFilterEditor/Info.plist new file mode 100644 index 00000000..491ec2ac --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/Info.plist @@ -0,0 +1,68 @@ + + + + + NSPhotoLibraryUsageDescription + User can filter photos + NSPhotoLibraryAddUsageDescription + User can add photos + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ImageFilterEditor/ImageFilterEditor/SceneDelegate.swift b/ImageFilterEditor/ImageFilterEditor/SceneDelegate.swift new file mode 100644 index 00000000..87746ffc --- /dev/null +++ b/ImageFilterEditor/ImageFilterEditor/SceneDelegate.swift @@ -0,0 +1,53 @@ +// +// SceneDelegate.swift +// ImageFilterEditor +// +// Created by Morgan Smith on 9/2/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index 93c4b93e..fea207c2 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -105,8 +105,8 @@ class ImagePostViewController: ShiftableViewController { print("FatalError") } presentImagePickerController() + } - func setImageViewHeight(with aspectRatio: CGFloat) { imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio From 44d269605f92f2680d4d77373ede6a85bb99bb92 Mon Sep 17 00:00:00 2001 From: morgan smith Date: Thu, 3 Sep 2020 19:05:41 -0400 Subject: [PATCH 2/7] finished integrating everything except the posting, issue with filtered data --- .../Base.lproj/Main.storyboard | 22 +- .../BlurViewController.swift | 2 +- LambdaTimeline.xcodeproj/project.pbxproj | 44 +- .../BlurViewController.swift | 110 ++++ .../ColorInvertViewController.swift | 89 +++ .../ColorViewController.swift | 112 ++++ .../DistortionViewController.swift | 114 ++++ .../SharpenViewController.swift | 93 +++ LambdaTimeline/GoogleService-Info.plist | 36 ++ .../Helpers/Extensions/UIImage+Scaling.swift | 40 ++ LambdaTimeline/Resources/Info.plist | 2 +- .../Storyboards/Base.lproj/Main.storyboard | 543 +++++++++++++++++- .../ImagePostViewController.swift | 65 ++- Podfile.lock | 2 +- 14 files changed, 1232 insertions(+), 42 deletions(-) create mode 100644 LambdaTimeline/Filter View Controllers/BlurViewController.swift create mode 100644 LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift create mode 100644 LambdaTimeline/Filter View Controllers/ColorViewController.swift create mode 100644 LambdaTimeline/Filter View Controllers/DistortionViewController.swift create mode 100644 LambdaTimeline/Filter View Controllers/SharpenViewController.swift create mode 100644 LambdaTimeline/GoogleService-Info.plist create mode 100644 LambdaTimeline/Helpers/Extensions/UIImage+Scaling.swift diff --git a/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard b/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard index 5dd4b030..070abde0 100644 --- a/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard +++ b/ImageFilterEditor/ImageFilterEditor/Base.lproj/Main.storyboard @@ -404,16 +404,16 @@ - + - + @@ -422,7 +422,7 @@ - + @@ -559,16 +559,16 @@ - + - + @@ -578,7 +578,7 @@ - + @@ -620,7 +620,7 @@ - + diff --git a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift index 7836f65f..0fc5c4e7 100644 --- a/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift +++ b/ImageFilterEditor/ImageFilterEditor/Filter View Controllers/BlurViewController.swift @@ -99,7 +99,7 @@ class BlurViewController: UIViewController { override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if segue.identifier == "saveBlur" { if let imageVC = segue.destination as? ImagePostViewController { - imageVC.filteredImage = imageView.image + imageVC.filteredImage = imageView.image } } } diff --git a/LambdaTimeline.xcodeproj/project.pbxproj b/LambdaTimeline.xcodeproj/project.pbxproj index 95ea1e3d..0b2cee38 100644 --- a/LambdaTimeline.xcodeproj/project.pbxproj +++ b/LambdaTimeline.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + 02C7B57625015DCC00C4A310 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 02C7B57525015DCC00C4A310 /* GoogleService-Info.plist */; }; + 02C7B579250184C400C4A310 /* BlurViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B578250184C300C4A310 /* BlurViewController.swift */; }; + 02C7B57B250184E200C4A310 /* ColorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B57A250184E200C4A310 /* ColorViewController.swift */; }; + 02C7B57D250184F500C4A310 /* DistortionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B57C250184F500C4A310 /* DistortionViewController.swift */; }; + 02C7B57F2501850200C4A310 /* SharpenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B57E2501850200C4A310 /* SharpenViewController.swift */; }; + 02C7B5812501850F00C4A310 /* ColorInvertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */; }; + 02C7B5832501857600C4A310 /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */; }; 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */; }; 46463781216FDE4B00E7FF73 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4646377F216FDE4B00E7FF73 /* Main.storyboard */; }; 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46463782216FDE4C00E7FF73 /* Assets.xcassets */; }; @@ -33,6 +40,13 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 02C7B57525015DCC00C4A310 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 02C7B578250184C300C4A310 /* BlurViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurViewController.swift; sourceTree = ""; }; + 02C7B57A250184E200C4A310 /* ColorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorViewController.swift; sourceTree = ""; }; + 02C7B57C250184F500C4A310 /* DistortionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DistortionViewController.swift; sourceTree = ""; }; + 02C7B57E2501850200C4A310 /* SharpenViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharpenViewController.swift; sourceTree = ""; }; + 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInvertViewController.swift; sourceTree = ""; }; + 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; 33A2B89DE9E21538434BD640 /* Pods-LambdaTimeline.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LambdaTimeline.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline.debug.xcconfig"; sourceTree = ""; }; 46463778216FDE4B00E7FF73 /* LambdaTimeline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LambdaTimeline.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -74,6 +88,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 02C7B5772501849800C4A310 /* Filter View Controllers */ = { + isa = PBXGroup; + children = ( + 02C7B578250184C300C4A310 /* BlurViewController.swift */, + 02C7B57A250184E200C4A310 /* ColorViewController.swift */, + 02C7B57C250184F500C4A310 /* DistortionViewController.swift */, + 02C7B57E2501850200C4A310 /* SharpenViewController.swift */, + 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */, + ); + path = "Filter View Controllers"; + sourceTree = ""; + }; 2B7D7DE8669C7C706445FCCC /* Pods */ = { isa = PBXGroup; children = ( @@ -104,6 +130,8 @@ 4646377A216FDE4B00E7FF73 /* LambdaTimeline */ = { isa = PBXGroup; children = ( + 02C7B57525015DCC00C4A310 /* GoogleService-Info.plist */, + 02C7B5772501849800C4A310 /* Filter View Controllers */, 46CFE7042171572900E7FF73 /* Views */, 46A0366B21700F6100E7FF73 /* View Controllers */, 4646379A2170090900E7FF73 /* Model Controllers */, @@ -199,6 +227,7 @@ 46CFE6F22170757F00E7FF73 /* User+DictionaryRepresentation.swift */, 46D571F42173CF3E00E7FF73 /* UIImage+Ratio.swift */, 46CFE6F621707FA600E7FF73 /* UIViewController+InformationalAlert.swift */, + 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */, ); path = Extensions; sourceTree = ""; @@ -274,6 +303,7 @@ buildActionMask = 2147483647; files = ( 46463786216FDE4C00E7FF73 /* LaunchScreen.storyboard in Resources */, + 02C7B57625015DCC00C4A310 /* GoogleService-Info.plist in Resources */, 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */, 46463781216FDE4B00E7FF73 /* Main.storyboard in Resources */, ); @@ -346,17 +376,23 @@ buildActionMask = 2147483647; files = ( 46A0366D2170158900E7FF73 /* SignInViewController.swift in Sources */, + 02C7B5832501857600C4A310 /* UIImage+Scaling.swift in Sources */, 46CFE6FB21714E6100E7FF73 /* Author.swift in Sources */, 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */, + 02C7B579250184C400C4A310 /* BlurViewController.swift in Sources */, + 02C7B57B250184E200C4A310 /* ColorViewController.swift in Sources */, 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */, 46CFE6FD217154F500E7FF73 /* Networking.swift in Sources */, 46CFE6F521707D0000E7FF73 /* ImagePostViewController.swift in Sources */, 46CFE6FF2171556D00E7FF73 /* ConcurrentOperation.swift in Sources */, + 02C7B57D250184F500C4A310 /* DistortionViewController.swift in Sources */, 46463790216FFD1B00E7FF73 /* Post.swift in Sources */, 46CFE6F32170757F00E7FF73 /* User+DictionaryRepresentation.swift in Sources */, + 02C7B57F2501850200C4A310 /* SharpenViewController.swift in Sources */, 46D571F32172D43B00E7FF73 /* Cache.swift in Sources */, 46CFE7012171559500E7FF73 /* FetchMediaOperation.swift in Sources */, 46D571F82173FC2700E7FF73 /* ImagePostDetailTableViewController.swift in Sources */, + 02C7B5812501850F00C4A310 /* ColorInvertViewController.swift in Sources */, 46CFE7032171572600E7FF73 /* ImagePostCollectionViewCell.swift in Sources */, 4646379C2170091A00E7FF73 /* PostController.swift in Sources */, 464637992170048900E7FF73 /* FirebaseConvertible.swift in Sources */, @@ -511,13 +547,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = QQ666D93S6; INFOPLIST_FILE = "$(SRCROOT)/LambdaTimeline/Resources/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.morgansmith.timeline; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -530,13 +566,13 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = QQ666D93S6; INFOPLIST_FILE = "$(SRCROOT)/LambdaTimeline/Resources/Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.morgansmith.timeline; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/LambdaTimeline/Filter View Controllers/BlurViewController.swift b/LambdaTimeline/Filter View Controllers/BlurViewController.swift new file mode 100644 index 00000000..c6260ec5 --- /dev/null +++ b/LambdaTimeline/Filter View Controllers/BlurViewController.swift @@ -0,0 +1,110 @@ +// +// BlurViewController.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import CoreImage +import Photos + +class BlurViewController: UIViewController { + + @IBOutlet weak var blurSlider: UISlider! + @IBOutlet weak var imageView: UIImageView! + + private let context = CIContext(options: nil) + let filter = CIFilter(name: "CIGaussianBlur")! + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + setUpBlurSlider() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + + @IBAction func blurChanged(_ sender: UISlider) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(blurSlider.value, forKey: "inputRadius") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + func setUpBlurSlider() { + if let inputRadiusDictionary = filter.attributes["inputRadius"] as? [String: Any], + let sliderMin = inputRadiusDictionary[kCIAttributeSliderMin] as? Float, + let sliderMax = inputRadiusDictionary[kCIAttributeSliderMax] as? Float, + let identity = inputRadiusDictionary[kCIAttributeIdentity] as? Float { + + blurSlider.minimumValue = sliderMin + blurSlider.maximumValue = sliderMax + blurSlider.value = identity + } + } + + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveBlur" { + if let imageVC = segue.destination as? ImagePostViewController, let image = imageView.image { + imageVC.filteredImage = image + imageVC.filteredImageData = imagePostController + } + } + } + + } + diff --git a/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift b/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift new file mode 100644 index 00000000..d70f6d61 --- /dev/null +++ b/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift @@ -0,0 +1,89 @@ +// +// ColorInvertViewController.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import CoreImage +import Photos + +class ColorInvertViewController: UIViewController { + @IBOutlet weak var imageView: UIImageView! + private let context = CIContext(options: nil) + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + @IBAction func invertTapped(_ sender: Any) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let filter = CIFilter(name: "CIColorInvert")! + filter.setValue(ciImage, forKey: "inputImage") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveColorInvert" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + imageVC.filteredImageData = imagePostController + } + } + } + + } + diff --git a/LambdaTimeline/Filter View Controllers/ColorViewController.swift b/LambdaTimeline/Filter View Controllers/ColorViewController.swift new file mode 100644 index 00000000..5a0003cd --- /dev/null +++ b/LambdaTimeline/Filter View Controllers/ColorViewController.swift @@ -0,0 +1,112 @@ +// +// ColorViewController.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import CoreImage +import Photos + +class ColorViewController: UIViewController { + + @IBOutlet weak var brightnessSlider: UISlider! + @IBOutlet weak var contrastSlider: UISlider! + @IBOutlet weak var saturationSlider: UISlider! + @IBOutlet weak var imageView: UIImageView! + + private let context = CIContext(options: nil) + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func brightnessChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func contrastChanged(_ sender: Any) { + updateImage() + } + + @IBAction func saturationChanged(_ sender: Any) { + updateImage() + } + + + @IBAction func saveTapped(_ sender: Any) { + + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + + let filter = CIFilter(name: "CIColorControls")! + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(saturationSlider.value, forKey: "inputSaturation") + filter.setValue(brightnessSlider.value, forKey: "inputBrightness") + filter.setValue(contrastSlider.value, forKey: "inputContrast") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + + + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveColor" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + imageVC.filteredImageData = imagePostController + } + } + } + + + } + diff --git a/LambdaTimeline/Filter View Controllers/DistortionViewController.swift b/LambdaTimeline/Filter View Controllers/DistortionViewController.swift new file mode 100644 index 00000000..1512df97 --- /dev/null +++ b/LambdaTimeline/Filter View Controllers/DistortionViewController.swift @@ -0,0 +1,114 @@ +// +// DistortionViewController.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// +import UIKit +import CoreImage +import Photos + +class DistortionViewController: UIViewController { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var radiusSlider: UISlider! + @IBOutlet weak var angleSlider: UISlider! + @IBOutlet weak var xCenterSlider: UISlider! + @IBOutlet weak var yCenterSlider: UISlider! + + + private let context = CIContext(options: nil) + let filter = CIFilter(name: "CIVortexDistortion")! + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + + @IBAction func radiusChanged(_ sender: UISlider) { + updateImage() + } + @IBAction func angleChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func xCenterChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func yCenterChanged(_ sender: UISlider) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let center = CIVector(x: CGFloat(xCenterSlider.value), y: CGFloat(yCenterSlider.value)) + + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(radiusSlider.value, forKey: "inputRadius") + filter.setValue(angleSlider.value, forKey: "inputAngle") + filter.setValue(center, forKey: "inputCenter") + + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveDistortion" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + imageVC.filteredImageData = imagePostController + } + } + } + + } diff --git a/LambdaTimeline/Filter View Controllers/SharpenViewController.swift b/LambdaTimeline/Filter View Controllers/SharpenViewController.swift new file mode 100644 index 00000000..5f878dd2 --- /dev/null +++ b/LambdaTimeline/Filter View Controllers/SharpenViewController.swift @@ -0,0 +1,93 @@ +// +// SharpenViewController.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import CoreImage +import Photos + +class SharpenViewController: UIViewController { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var sharpenSlider: UISlider! + + private let context = CIContext(options: nil) + + let imagePostController = ImagePostViewController.sharedPhoto + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage + } + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage + } + } + + + override func viewDidLoad() { + super.viewDidLoad() + imageView.image = imagePostController + originalImage = imageView.image + } + + @IBAction func saveTapped(_ sender: Any) { + + } + @IBAction func sharpenTapped(_ sender: Any) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let filter = CIFilter(name: "CISharpenLuminance")! + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(sharpenSlider.value, forKey: "inputSharpness") + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveSharpen" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image + imageVC.filteredImageData = imagePostController + } + } + } + + } + + diff --git a/LambdaTimeline/GoogleService-Info.plist b/LambdaTimeline/GoogleService-Info.plist new file mode 100644 index 00000000..801c6a4a --- /dev/null +++ b/LambdaTimeline/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + 408849830962-1k5k0es64ef2la2sa67att0u07tosd87.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.408849830962-1k5k0es64ef2la2sa67att0u07tosd87 + API_KEY + AIzaSyBykBDXjQrcDxtFZPQ4RymzQ_Ahx9ag6Es + GCM_SENDER_ID + 408849830962 + PLIST_VERSION + 1 + BUNDLE_ID + com.morgansmith.timeline + PROJECT_ID + morgan-s-timeline + STORAGE_BUCKET + morgan-s-timeline.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:408849830962:ios:c98be8ae0f90273ff3d82b + DATABASE_URL + https://morgan-s-timeline.firebaseio.com + + \ No newline at end of file diff --git a/LambdaTimeline/Helpers/Extensions/UIImage+Scaling.swift b/LambdaTimeline/Helpers/Extensions/UIImage+Scaling.swift new file mode 100644 index 00000000..c5f5736f --- /dev/null +++ b/LambdaTimeline/Helpers/Extensions/UIImage+Scaling.swift @@ -0,0 +1,40 @@ +// +// UIImage+Scaling.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit + +extension UIImage { + + /// Resize the image to a max dimension from size parameter + func imageByScaling(toSize size: CGSize) -> UIImage? { + guard size.width > 0 && size.height > 0 else { return nil } + + let originalAspectRatio = self.size.width/self.size.height + var correctedSize = size + + if correctedSize.width > correctedSize.width*originalAspectRatio { + correctedSize.width = correctedSize.width*originalAspectRatio + } else { + correctedSize.height = correctedSize.height/originalAspectRatio + } + + return UIGraphicsImageRenderer(size: correctedSize, format: imageRendererFormat).image { context in + draw(in: CGRect(origin: .zero, size: correctedSize)) + } + } + + /// Renders the image if the pixel data was rotated due to orientation of camera + var flattened: UIImage { + if imageOrientation == .up { return self } + return UIGraphicsImageRenderer(size: size, format: imageRendererFormat).image { context in + draw(at: .zero) + } + } +} + + diff --git a/LambdaTimeline/Resources/Info.plist b/LambdaTimeline/Resources/Info.plist index dab10c0a..1f172fc7 100644 --- a/LambdaTimeline/Resources/Info.plist +++ b/LambdaTimeline/Resources/Info.plist @@ -23,7 +23,7 @@ Editor CFBundleURLSchemes - Copy-paste-your-REVERSED_CLIENT_ID-from-GoogleService-Info.plist + com.googleusercontent.apps.408849830962-1k5k0es64ef2la2sa67att0u07tosd87 diff --git a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard index fcec352d..8390ad7d 100644 --- a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard +++ b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard @@ -1,10 +1,11 @@ - + - + + @@ -111,16 +112,16 @@ - + - + - + @@ -150,8 +151,17 @@ + - + @@ -187,6 +197,7 @@ + @@ -353,5 +364,525 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index fea207c2..393555f8 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -7,62 +7,76 @@ // import UIKit +import CoreImage import Photos class ImagePostViewController: ShiftableViewController { override func viewDidLoad() { super.viewDidLoad() - + setImageViewHeight(with: 1.0) - - updateViews() + + if let filtered = filteredImage { + imageView.image = filtered + setImageViewHeight(with: filtered.ratio) + title = post?.title ?? "New Post" + chooseImageButton.setTitle("", for: []) + editPhoto.setTitleColor(.systemBlue, for: .normal) + } else { + updateViews() + } } func updateViews() { + editPhoto.setTitle("Edit Photo", for: .normal) guard let imageData = imageData, let image = UIImage(data: imageData) else { title = "New Post" + editPhoto.setTitleColor(.white, for: .normal) return } title = post?.title - + setImageViewHeight(with: image.ratio) imageView.image = image - + + editPhoto.setTitleColor(.systemBlue, for: .normal) + chooseImageButton.setTitle("", for: []) } - + private func presentImagePickerController() { guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { presentInformationalAlertController(title: "Error", message: "The photo library is unavailable") return } - - let imagePicker = UIImagePickerController() - - imagePicker.delegate = self - - imagePicker.sourceType = .photoLibrary + DispatchQueue.main.async { + let imagePicker = UIImagePickerController() + + imagePicker.delegate = self + + imagePicker.sourceType = .photoLibrary - present(imagePicker, animated: true, completion: nil) + self.present(imagePicker, animated: true, completion: nil) + } } @IBAction func createPost(_ sender: Any) { view.endEditing(true) - guard let imageData = imageView.image?.jpegData(compressionQuality: 0.1), - let title = titleTextField.text, title != "" else { - presentInformationalAlertController(title: "Uh-oh", message: "Make sure that you add a photo and a caption before posting.") - return + guard let imageData = filteredImageData?.jpegData(compressionQuality: 0.1) ?? imageView.image?.jpegData(compressionQuality: 0.1), + let title = titleTextField.text, title != "", let ratio = imageView.image?.ratio else { + presentInformationalAlertController(title: "Uh-oh", message: "Make sure that you add a photo and a caption before posting.") + return } - postController.createPost(with: title, ofType: .image, mediaData: imageData, ratio: imageView.image?.ratio) { (success) in + postController.createPost(with: title, ofType: .image, mediaData: imageData, ratio: ratio) { (success) in guard success else { DispatchQueue.main.async { self.presentInformationalAlertController(title: "Error", message: "Unable to create post. Try again.") @@ -107,6 +121,13 @@ class ImagePostViewController: ShiftableViewController { presentImagePickerController() } + + @IBAction func editPhoto(_ sender: Any) { + guard let image = imageView.image else { return } + ImagePostViewController.sharedPhoto = image + } + + func setImageViewHeight(with aspectRatio: CGFloat) { imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio @@ -117,7 +138,11 @@ class ImagePostViewController: ShiftableViewController { var postController: PostController! var post: Post? var imageData: Data? + var filteredImage: UIImage? + var filteredImageData: UIImage? + static var sharedPhoto = UIImage() + @IBOutlet weak var editPhoto: UIButton! @IBOutlet weak var imageView: UIImageView! @IBOutlet weak var titleTextField: UITextField! @IBOutlet weak var chooseImageButton: UIButton! @@ -137,6 +162,10 @@ extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigation imageView.image = image + ImagePostViewController.sharedPhoto = image + + editPhoto.setTitleColor(.systemBlue, for: .normal) + setImageViewHeight(with: image.ratio) } diff --git a/Podfile.lock b/Podfile.lock index 9d835784..244d90e4 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -171,4 +171,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 28424f1a99ace6e65529f57867177322416678fa -COCOAPODS: 1.9.1 +COCOAPODS: 1.9.3 From fac065f37dfbc10a7bdfab0a58736fd5de8b9e3a Mon Sep 17 00:00:00 2001 From: morgan smith Date: Sat, 5 Sep 2020 21:07:35 -0400 Subject: [PATCH 3/7] changed data passing from imagePostViewController to tab controllers to a segue --- LambdaTimeline.xcodeproj/project.pbxproj | 4 + .../BlurViewController.swift | 179 +++++++------- .../ColorInvertViewController.swift | 156 +++++++----- .../ColorViewController.swift | 199 +++++++++------- .../DistortionViewController.swift | 212 +++++++++-------- .../SharpenViewController.swift | 164 +++++++------ .../Helpers/Extensions/CIImage+JPEG.swift | 44 ++++ .../Storyboards/Base.lproj/Main.storyboard | 225 +++++++++++------- .../ImagePostViewController.swift | 105 +++++--- 9 files changed, 753 insertions(+), 535 deletions(-) create mode 100644 LambdaTimeline/Helpers/Extensions/CIImage+JPEG.swift diff --git a/LambdaTimeline.xcodeproj/project.pbxproj b/LambdaTimeline.xcodeproj/project.pbxproj index 0b2cee38..baa9bef8 100644 --- a/LambdaTimeline.xcodeproj/project.pbxproj +++ b/LambdaTimeline.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 02C7B57F2501850200C4A310 /* SharpenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B57E2501850200C4A310 /* SharpenViewController.swift */; }; 02C7B5812501850F00C4A310 /* ColorInvertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */; }; 02C7B5832501857600C4A310 /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */; }; + 02C7B5852501B54600C4A310 /* CIImage+JPEG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5842501B54600C4A310 /* CIImage+JPEG.swift */; }; 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */; }; 46463781216FDE4B00E7FF73 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4646377F216FDE4B00E7FF73 /* Main.storyboard */; }; 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46463782216FDE4C00E7FF73 /* Assets.xcassets */; }; @@ -47,6 +48,7 @@ 02C7B57E2501850200C4A310 /* SharpenViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharpenViewController.swift; sourceTree = ""; }; 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInvertViewController.swift; sourceTree = ""; }; 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; + 02C7B5842501B54600C4A310 /* CIImage+JPEG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CIImage+JPEG.swift"; sourceTree = ""; }; 33A2B89DE9E21538434BD640 /* Pods-LambdaTimeline.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LambdaTimeline.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline.debug.xcconfig"; sourceTree = ""; }; 46463778216FDE4B00E7FF73 /* LambdaTimeline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LambdaTimeline.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -228,6 +230,7 @@ 46D571F42173CF3E00E7FF73 /* UIImage+Ratio.swift */, 46CFE6F621707FA600E7FF73 /* UIViewController+InformationalAlert.swift */, 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */, + 02C7B5842501B54600C4A310 /* CIImage+JPEG.swift */, ); path = Extensions; sourceTree = ""; @@ -380,6 +383,7 @@ 46CFE6FB21714E6100E7FF73 /* Author.swift in Sources */, 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */, 02C7B579250184C400C4A310 /* BlurViewController.swift in Sources */, + 02C7B5852501B54600C4A310 /* CIImage+JPEG.swift in Sources */, 02C7B57B250184E200C4A310 /* ColorViewController.swift in Sources */, 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */, 46CFE6FD217154F500E7FF73 /* Networking.swift in Sources */, diff --git a/LambdaTimeline/Filter View Controllers/BlurViewController.swift b/LambdaTimeline/Filter View Controllers/BlurViewController.swift index c6260ec5..4ae74c96 100644 --- a/LambdaTimeline/Filter View Controllers/BlurViewController.swift +++ b/LambdaTimeline/Filter View Controllers/BlurViewController.swift @@ -11,100 +11,107 @@ import CoreImage import Photos class BlurViewController: UIViewController { - + @IBOutlet weak var blurSlider: UISlider! @IBOutlet weak var imageView: UIImageView! - - private let context = CIContext(options: nil) - let filter = CIFilter(name: "CIGaussianBlur")! - - let imagePostController = ImagePostViewController.sharedPhoto - - var originalImage: UIImage? { - didSet { - guard let image = originalImage else { return } - - var scaledSize = imageView.bounds.size - let scale = UIScreen.main.scale - - scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) - - let scaledImage = image.imageByScaling(toSize: scaledSize) - self.scaledImage = scaledImage - } - } - - var scaledImage: UIImage? { - didSet { - imageView.image = scaledImage - } + + private let context = CIContext(options: nil) + let filter = CIFilter(name: "CIGaussianBlur")! + + var originalImagePost: UIImage? + var savedPhoto: UIImage? + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage } - - - override func viewDidLoad() { - super.viewDidLoad() - setUpBlurSlider() - imageView.image = imagePostController - originalImage = imageView.image + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage } - + } + + + override func viewDidLoad() { + super.viewDidLoad() + setUpBlurSlider() + guard let postImage = originalImagePost else { return } + originalImage = postImage + imageView.image = originalImage + } + @IBAction func saveTapped(_ sender: Any) { - + + } + + @IBAction func doneEditing(_ sender: Any) { + + } + + + @IBAction func blurChanged(_ sender: UISlider) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + + } else { + imageView.image = nil } - - @IBAction func blurChanged(_ sender: UISlider) { - updateImage() + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(blurSlider.value, forKey: "inputRadius") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + savedPhoto = UIImage(cgImage: outputCGImage) + return UIImage(cgImage: outputCGImage) + } + + + func setUpBlurSlider() { + if let inputRadiusDictionary = filter.attributes["inputRadius"] as? [String: Any], + let sliderMin = inputRadiusDictionary[kCIAttributeSliderMin] as? Float, + let sliderMax = inputRadiusDictionary[kCIAttributeSliderMax] as? Float, + let identity = inputRadiusDictionary[kCIAttributeIdentity] as? Float { + + blurSlider.minimumValue = sliderMin + blurSlider.maximumValue = sliderMax + blurSlider.value = identity } - - - private func updateImage() { - if let scaledImage = scaledImage { - imageView.image = image(byFiltering: scaledImage) - - } else { - imageView.image = nil - } - } - - private func image(byFiltering image: UIImage) -> UIImage { - - guard let cgImage = image.cgImage else { return image} - - let ciImage = CIImage(cgImage: cgImage) - - filter.setValue(ciImage, forKey: "inputImage") - filter.setValue(blurSlider.value, forKey: "inputRadius") - - guard let outputImage = filter.outputImage else { return image } - - guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } - - return UIImage(cgImage: outputCGImage) - } - - func setUpBlurSlider() { - if let inputRadiusDictionary = filter.attributes["inputRadius"] as? [String: Any], - let sliderMin = inputRadiusDictionary[kCIAttributeSliderMin] as? Float, - let sliderMax = inputRadiusDictionary[kCIAttributeSliderMax] as? Float, - let identity = inputRadiusDictionary[kCIAttributeIdentity] as? Float { - - blurSlider.minimumValue = sliderMin - blurSlider.maximumValue = sliderMax - blurSlider.value = identity + } + + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveBlur" { + if let imageVC = segue.destination as? ImagePostViewController, let photo = savedPhoto { + imageVC.filteredImage = photo } } - - - // MARK: - Navigation - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "saveBlur" { - if let imageVC = segue.destination as? ImagePostViewController, let image = imageView.image { - imageVC.filteredImage = image - imageVC.filteredImageData = imagePostController - } - } - } - } + +} diff --git a/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift b/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift index d70f6d61..5ccae1e1 100644 --- a/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift +++ b/LambdaTimeline/Filter View Controllers/ColorInvertViewController.swift @@ -12,78 +12,102 @@ import Photos class ColorInvertViewController: UIViewController { @IBOutlet weak var imageView: UIImageView! - private let context = CIContext(options: nil) - - let imagePostController = ImagePostViewController.sharedPhoto - - var originalImage: UIImage? { - didSet { - guard let image = originalImage else { return } - - var scaledSize = imageView.bounds.size - let scale = UIScreen.main.scale - - scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) - - let scaledImage = image.imageByScaling(toSize: scaledSize) - self.scaledImage = scaledImage - } - } - - var scaledImage: UIImage? { - didSet { - imageView.image = scaledImage - } + private let context = CIContext(options: nil) + var originalImagePost: UIImage? + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage } - - - override func viewDidLoad() { - super.viewDidLoad() - imageView.image = imagePostController - originalImage = imageView.image + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage } - - @IBAction func saveTapped(_ sender: Any) { - + } + + + override func viewDidLoad() { + super.viewDidLoad() + guard let postImage = originalImagePost else { return } + originalImage = postImage + imageView.image = originalImage + } + + @IBAction func saveTapped(_ sender: Any) { + guard let originalImage = originalImage else { return } + + let filteredImage = image(byFiltering: originalImage) + + PHPhotoLibrary.requestAuthorization { (status) in + + guard status == .authorized else { + NSLog("The user has not authorized permission for photo library usage.") + return + + PHPhotoLibrary.shared().performChanges({ + + PHAssetCreationRequest.creationRequestForAsset(from: filteredImage) + + }) { (success, error) in + if let error = error { + NSLog("Error saving photo asset: \(error)") + return + } + + } + } + } + + + } @IBAction func invertTapped(_ sender: Any) { updateImage() } - - - private func updateImage() { - if let scaledImage = scaledImage { - imageView.image = image(byFiltering: scaledImage) - } else { - imageView.image = nil - } - } - - private func image(byFiltering image: UIImage) -> UIImage { - - guard let cgImage = image.cgImage else { return image} - - let ciImage = CIImage(cgImage: cgImage) - let filter = CIFilter(name: "CIColorInvert")! - filter.setValue(ciImage, forKey: "inputImage") - - guard let outputImage = filter.outputImage else { return image } - - guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } - - return UIImage(cgImage: outputCGImage) - } - - // MARK: - Navigation - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "saveColorInvert" { - if let imageVC = segue.destination as? ImagePostViewController { - imageVC.filteredImage = imageView.image - imageVC.filteredImageData = imagePostController - } - } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let filter = CIFilter(name: "CIColorInvert")! + filter.setValue(ciImage, forKey: "inputImage") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveColorInvert" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image } - + } } + +} diff --git a/LambdaTimeline/Filter View Controllers/ColorViewController.swift b/LambdaTimeline/Filter View Controllers/ColorViewController.swift index 5a0003cd..6ed1ff1c 100644 --- a/LambdaTimeline/Filter View Controllers/ColorViewController.swift +++ b/LambdaTimeline/Filter View Controllers/ColorViewController.swift @@ -11,102 +11,123 @@ import CoreImage import Photos class ColorViewController: UIViewController { - + @IBOutlet weak var brightnessSlider: UISlider! - @IBOutlet weak var contrastSlider: UISlider! - @IBOutlet weak var saturationSlider: UISlider! - @IBOutlet weak var imageView: UIImageView! - - private let context = CIContext(options: nil) - - let imagePostController = ImagePostViewController.sharedPhoto - - var originalImage: UIImage? { - didSet { - guard let image = originalImage else { return } - - var scaledSize = imageView.bounds.size - let scale = UIScreen.main.scale - - scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) - - let scaledImage = image.imageByScaling(toSize: scaledSize) - self.scaledImage = scaledImage - } - } - - var scaledImage: UIImage? { - didSet { - imageView.image = scaledImage - } + @IBOutlet weak var contrastSlider: UISlider! + @IBOutlet weak var saturationSlider: UISlider! + @IBOutlet weak var imageView: UIImageView! + + private let context = CIContext(options: nil) + var originalImagePost: UIImage? + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage } - - - override func viewDidLoad() { - super.viewDidLoad() - imageView.image = imagePostController - originalImage = imageView.image - } - - @IBAction func brightnessChanged(_ sender: UISlider) { - updateImage() - } - - @IBAction func contrastChanged(_ sender: Any) { - updateImage() - } - - @IBAction func saturationChanged(_ sender: Any) { - updateImage() - } - - - @IBAction func saveTapped(_ sender: Any) { - + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage } - - - private func updateImage() { - if let scaledImage = scaledImage { - imageView.image = image(byFiltering: scaledImage) - } else { - imageView.image = nil + } + + + override func viewDidLoad() { + super.viewDidLoad() + guard let postImage = originalImagePost else { return } + originalImage = postImage + imageView.image = originalImage + } + + @IBAction func brightnessChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func contrastChanged(_ sender: Any) { + updateImage() + } + + @IBAction func saturationChanged(_ sender: Any) { + updateImage() + } + + + @IBAction func saveTapped(_ sender: Any) { + guard let originalImage = originalImage else { return } + + let filteredImage = image(byFiltering: originalImage) + + PHPhotoLibrary.requestAuthorization { (status) in + + guard status == .authorized else { + NSLog("The user has not authorized permission for photo library usage.") + return + + PHPhotoLibrary.shared().performChanges({ + + PHAssetCreationRequest.creationRequestForAsset(from: filteredImage) + + }) { (success, error) in + if let error = error { + NSLog("Error saving photo asset: \(error)") + return + } + + } } + } - - private func image(byFiltering image: UIImage) -> UIImage { - - guard let cgImage = image.cgImage else { return image} - - let ciImage = CIImage(cgImage: cgImage) - - let filter = CIFilter(name: "CIColorControls")! - filter.setValue(ciImage, forKey: "inputImage") - filter.setValue(saturationSlider.value, forKey: "inputSaturation") - filter.setValue(brightnessSlider.value, forKey: "inputBrightness") - filter.setValue(contrastSlider.value, forKey: "inputContrast") - - guard let outputImage = filter.outputImage else { return image } - - guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } - - return UIImage(cgImage: outputCGImage) + + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil } - - - - // MARK: - Navigation - - // In a storyboard-based application, you will often want to do a little preparation before navigation - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "saveColor" { - if let imageVC = segue.destination as? ImagePostViewController { - imageVC.filteredImage = imageView.image - imageVC.filteredImageData = imagePostController - } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + + let filter = CIFilter(name: "CIColorControls")! + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(saturationSlider.value, forKey: "inputSaturation") + filter.setValue(brightnessSlider.value, forKey: "inputBrightness") + filter.setValue(contrastSlider.value, forKey: "inputContrast") + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + + + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveColor" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image } } - - } +} diff --git a/LambdaTimeline/Filter View Controllers/DistortionViewController.swift b/LambdaTimeline/Filter View Controllers/DistortionViewController.swift index 1512df97..cf2f2a58 100644 --- a/LambdaTimeline/Filter View Controllers/DistortionViewController.swift +++ b/LambdaTimeline/Filter View Controllers/DistortionViewController.swift @@ -10,105 +10,129 @@ import CoreImage import Photos class DistortionViewController: UIViewController { - - @IBOutlet weak var imageView: UIImageView! - @IBOutlet weak var radiusSlider: UISlider! - @IBOutlet weak var angleSlider: UISlider! - @IBOutlet weak var xCenterSlider: UISlider! - @IBOutlet weak var yCenterSlider: UISlider! - - - private let context = CIContext(options: nil) - let filter = CIFilter(name: "CIVortexDistortion")! - - let imagePostController = ImagePostViewController.sharedPhoto - - var originalImage: UIImage? { - didSet { - guard let image = originalImage else { return } - - var scaledSize = imageView.bounds.size - let scale = UIScreen.main.scale - - scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) - - let scaledImage = image.imageByScaling(toSize: scaledSize) - self.scaledImage = scaledImage - } - } - - var scaledImage: UIImage? { - didSet { - imageView.image = scaledImage - } - } - - - override func viewDidLoad() { - super.viewDidLoad() - imageView.image = imagePostController - originalImage = imageView.image - } - - @IBAction func saveTapped(_ sender: Any) { - - } - - @IBAction func radiusChanged(_ sender: UISlider) { - updateImage() + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var radiusSlider: UISlider! + @IBOutlet weak var angleSlider: UISlider! + @IBOutlet weak var xCenterSlider: UISlider! + @IBOutlet weak var yCenterSlider: UISlider! + + + private let context = CIContext(options: nil) + let filter = CIFilter(name: "CIVortexDistortion")! + var originalImagePost: UIImage? + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage } - @IBAction func angleChanged(_ sender: UISlider) { - updateImage() - } - - @IBAction func xCenterChanged(_ sender: UISlider) { - updateImage() - } - - @IBAction func yCenterChanged(_ sender: UISlider) { - updateImage() + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage } - - - private func updateImage() { - if let scaledImage = scaledImage { - imageView.image = image(byFiltering: scaledImage) - } else { - imageView.image = nil + } + + + override func viewDidLoad() { + super.viewDidLoad() + guard let postImage = originalImagePost else { return } + originalImage = postImage + imageView.image = originalImage + } + + @IBAction func saveTapped(_ sender: Any) { + guard let originalImage = originalImage else { return } + + let filteredImage = image(byFiltering: originalImage) + + PHPhotoLibrary.requestAuthorization { (status) in + + guard status == .authorized else { + NSLog("The user has not authorized permission for photo library usage.") + return + + PHPhotoLibrary.shared().performChanges({ + + PHAssetCreationRequest.creationRequestForAsset(from: filteredImage) + + }) { (success, error) in + if let error = error { + NSLog("Error saving photo asset: \(error)") + return + } + + } } + } - - private func image(byFiltering image: UIImage) -> UIImage { - - guard let cgImage = image.cgImage else { return image} - - let ciImage = CIImage(cgImage: cgImage) - let center = CIVector(x: CGFloat(xCenterSlider.value), y: CGFloat(yCenterSlider.value)) - - filter.setValue(ciImage, forKey: "inputImage") - filter.setValue(radiusSlider.value, forKey: "inputRadius") - filter.setValue(angleSlider.value, forKey: "inputAngle") - filter.setValue(center, forKey: "inputCenter") - - - guard let outputImage = filter.outputImage else { return image } - - guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } - - return UIImage(cgImage: outputCGImage) + + + } + + @IBAction func radiusChanged(_ sender: UISlider) { + updateImage() + } + @IBAction func angleChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func xCenterChanged(_ sender: UISlider) { + updateImage() + } + + @IBAction func yCenterChanged(_ sender: UISlider) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil } - - - - // MARK: - Navigation - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "saveDistortion" { - if let imageVC = segue.destination as? ImagePostViewController { - imageVC.filteredImage = imageView.image - imageVC.filteredImageData = imagePostController - } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let center = CIVector(x: CGFloat(xCenterSlider.value), y: CGFloat(yCenterSlider.value)) + + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(radiusSlider.value, forKey: "inputRadius") + filter.setValue(angleSlider.value, forKey: "inputAngle") + filter.setValue(center, forKey: "inputCenter") + + + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveDistortion" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image } } - } + +} diff --git a/LambdaTimeline/Filter View Controllers/SharpenViewController.swift b/LambdaTimeline/Filter View Controllers/SharpenViewController.swift index 5f878dd2..df14fbca 100644 --- a/LambdaTimeline/Filter View Controllers/SharpenViewController.swift +++ b/LambdaTimeline/Filter View Controllers/SharpenViewController.swift @@ -11,83 +11,107 @@ import CoreImage import Photos class SharpenViewController: UIViewController { - - @IBOutlet weak var imageView: UIImageView! - @IBOutlet weak var sharpenSlider: UISlider! - - private let context = CIContext(options: nil) - - let imagePostController = ImagePostViewController.sharedPhoto - - var originalImage: UIImage? { - didSet { - guard let image = originalImage else { return } - - var scaledSize = imageView.bounds.size - let scale = UIScreen.main.scale - - scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) - - let scaledImage = image.imageByScaling(toSize: scaledSize) - self.scaledImage = scaledImage - } - } - - var scaledImage: UIImage? { - didSet { - imageView.image = scaledImage - } - } - - - override func viewDidLoad() { - super.viewDidLoad() - imageView.image = imagePostController - originalImage = imageView.image + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var sharpenSlider: UISlider! + + private let context = CIContext(options: nil) + var originalImagePost: UIImage? + + var originalImage: UIImage? { + didSet { + guard let image = originalImage else { return } + + var scaledSize = imageView.bounds.size + let scale = UIScreen.main.scale + + scaledSize = CGSize(width: scaledSize.width * scale, height: scaledSize.height * scale) + + let scaledImage = image.imageByScaling(toSize: scaledSize) + self.scaledImage = scaledImage } - - @IBAction func saveTapped(_ sender: Any) { - - } - @IBAction func sharpenTapped(_ sender: Any) { - updateImage() + } + + var scaledImage: UIImage? { + didSet { + imageView.image = scaledImage } - - - private func updateImage() { - if let scaledImage = scaledImage { - imageView.image = image(byFiltering: scaledImage) - } else { - imageView.image = nil + } + + + override func viewDidLoad() { + super.viewDidLoad() + guard let postImage = originalImagePost else { return } + originalImage = postImage + imageView.image = originalImage + } + + @IBAction func saveTapped(_ sender: Any) { + guard let originalImage = originalImage else { return } + + let filteredImage = image(byFiltering: originalImage) + + PHPhotoLibrary.requestAuthorization { (status) in + + guard status == .authorized else { + NSLog("The user has not authorized permission for photo library usage.") + return + + PHPhotoLibrary.shared().performChanges({ + + PHAssetCreationRequest.creationRequestForAsset(from: filteredImage) + + }) { (success, error) in + if let error = error { + NSLog("Error saving photo asset: \(error)") + return + } + + } } + } - - private func image(byFiltering image: UIImage) -> UIImage { - - guard let cgImage = image.cgImage else { return image} - - let ciImage = CIImage(cgImage: cgImage) - let filter = CIFilter(name: "CISharpenLuminance")! - filter.setValue(ciImage, forKey: "inputImage") - filter.setValue(sharpenSlider.value, forKey: "inputSharpness") - guard let outputImage = filter.outputImage else { return image } - - guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } - - return UIImage(cgImage: outputCGImage) + + + } + @IBAction func sharpenTapped(_ sender: Any) { + updateImage() + } + + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil } - - // MARK: - Navigation - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - if segue.identifier == "saveSharpen" { - if let imageVC = segue.destination as? ImagePostViewController { - imageVC.filteredImage = imageView.image - imageVC.filteredImageData = imagePostController - } + } + + private func image(byFiltering image: UIImage) -> UIImage { + + guard let cgImage = image.cgImage else { return image} + + let ciImage = CIImage(cgImage: cgImage) + let filter = CIFilter(name: "CISharpenLuminance")! + filter.setValue(ciImage, forKey: "inputImage") + filter.setValue(sharpenSlider.value, forKey: "inputSharpness") + guard let outputImage = filter.outputImage else { return image } + + guard let outputCGImage = context.createCGImage(outputImage, from: outputImage.extent) else { return image } + + return UIImage(cgImage: outputCGImage) + } + + // MARK: - Navigation + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveSharpen" { + if let imageVC = segue.destination as? ImagePostViewController { + imageVC.filteredImage = imageView.image } } - } + +} diff --git a/LambdaTimeline/Helpers/Extensions/CIImage+JPEG.swift b/LambdaTimeline/Helpers/Extensions/CIImage+JPEG.swift new file mode 100644 index 00000000..423b71cf --- /dev/null +++ b/LambdaTimeline/Helpers/Extensions/CIImage+JPEG.swift @@ -0,0 +1,44 @@ +// +// CIImage+JPEG.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/3/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import CoreImage +import Photos +extension CIImage { + + func saveJPEG(_ name:String, inDirectoryURL:URL? = nil, quality:CGFloat = 1.0) -> String? { + + var destinationURL = inDirectoryURL + + if destinationURL == nil { + destinationURL = try? FileManager.default.url(for:.documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + } + + if var destinationURL = destinationURL { + + destinationURL = destinationURL.appendingPathComponent(name) + + if let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) { + + do { + + let context = CIContext() + + try context.writeJPEGRepresentation(of: self, to: destinationURL, colorSpace: colorSpace, options: [kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption : quality]) + + return destinationURL.path + + } catch { + return nil + } + } + } + + return nil + } +} diff --git a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard index 8390ad7d..115645fc 100644 --- a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard +++ b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard @@ -157,7 +157,7 @@ - + @@ -398,30 +398,29 @@ - + - - + - + - + - + - + @@ -429,7 +428,7 @@ - - - + - - - - + + + - - - - - - - + + + + + + + @@ -487,18 +492,17 @@ - + - - - + + + - - - - - - + + + + + + @@ -550,18 +564,17 @@ - + - - + - + - + @@ -578,7 +591,7 @@ + - + - - - - - + + + + + - + @@ -625,18 +648,17 @@ - + - - + - + - + @@ -653,7 +675,7 @@ - + - + @@ -670,7 +692,7 @@ - + - + @@ -687,7 +709,7 @@ - + - + @@ -704,7 +726,7 @@ + @@ -723,12 +755,13 @@ - - - - - - + + + + + + + @@ -754,21 +787,20 @@ - + - - + - + - + - + @@ -785,7 +817,7 @@ - + - + @@ -802,7 +834,7 @@ - + - + @@ -819,7 +851,7 @@ + - + @@ -841,23 +883,23 @@ - - - - + + + + - - - - - - - + + + + + + + @@ -880,7 +922,8 @@ - + + diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index 393555f8..8a1abecf 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -12,18 +12,35 @@ import Photos class ImagePostViewController: ShiftableViewController { + @IBOutlet weak var editPhoto: UIButton! + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleTextField: UITextField! + @IBOutlet weak var chooseImageButton: UIButton! + @IBOutlet weak var imageHeightConstraint: NSLayoutConstraint! + @IBOutlet weak var postButton: UIBarButtonItem! + + var postController: PostController! + var post: Post? + var imageData: Data? + var filteredImage: UIImage? + var originalImagePost: UIImage? { + didSet { + print("set the original image in postController") + viewDidLoad() + } + } + override func viewDidLoad() { super.viewDidLoad() - - setImageViewHeight(with: 1.0) - - if let filtered = filteredImage { - imageView.image = filtered - setImageViewHeight(with: filtered.ratio) - title = post?.title ?? "New Post" + if let filteredImage = filteredImage { + setImageViewHeight(with: filteredImage.ratio) + imageView.image = filteredImage + editPhoto.setTitle("Edit Photo", for: .normal) + editPhoto.setTitleColor(.systemBlue, for: .normal) + title = "New Post" chooseImageButton.setTitle("", for: []) - editPhoto.setTitleColor(.systemBlue, for: .normal) } else { + setImageViewHeight(with: 1.0) updateViews() } } @@ -39,16 +56,16 @@ class ImagePostViewController: ShiftableViewController { } title = post?.title - + setImageViewHeight(with: image.ratio) imageView.image = image - + editPhoto.setTitleColor(.systemBlue, for: .normal) - + chooseImageButton.setTitle("", for: []) } - + private func presentImagePickerController() { guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { @@ -57,11 +74,11 @@ class ImagePostViewController: ShiftableViewController { } DispatchQueue.main.async { let imagePicker = UIImagePickerController() - + imagePicker.delegate = self - + imagePicker.sourceType = .photoLibrary - + self.present(imagePicker, animated: true, completion: nil) } } @@ -70,7 +87,7 @@ class ImagePostViewController: ShiftableViewController { view.endEditing(true) - guard let imageData = filteredImageData?.jpegData(compressionQuality: 0.1) ?? imageView.image?.jpegData(compressionQuality: 0.1), + guard let imageData = imageView.image?.jpegData(compressionQuality: 0.1), let title = titleTextField.text, title != "", let ratio = imageView.image?.ratio else { presentInformationalAlertController(title: "Uh-oh", message: "Make sure that you add a photo and a caption before posting.") return @@ -119,15 +136,13 @@ class ImagePostViewController: ShiftableViewController { print("FatalError") } presentImagePickerController() - + } - + @IBAction func editPhoto(_ sender: Any) { - guard let image = imageView.image else { return } - ImagePostViewController.sharedPhoto = image } - - + + func setImageViewHeight(with aspectRatio: CGFloat) { imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio @@ -135,25 +150,37 @@ class ImagePostViewController: ShiftableViewController { view.layoutSubviews() } - var postController: PostController! - var post: Post? - var imageData: Data? - var filteredImage: UIImage? - var filteredImageData: UIImage? - static var sharedPhoto = UIImage() + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "editPhotoSegue" { + if let tabController = segue.destination as? UITabBarController { + let blurController = tabController.children.first(where: { $0 is BlurViewController }) + (blurController as? BlurViewController)?.originalImagePost = self.originalImagePost + + let colorController = tabController.children.first(where: { $0 is ColorViewController }) + (colorController as? ColorViewController)?.originalImagePost = self.originalImagePost + + let distortionController = tabController.children.first(where: { $0 is DistortionViewController }) + (distortionController as? DistortionViewController)?.originalImagePost = self.originalImagePost + let sharpenController = tabController.children.first(where: { $0 is SharpenViewController }) + (sharpenController as? SharpenViewController)?.originalImagePost = self.originalImagePost + let colorInvertController = tabController.children.first(where: { $0 is ColorInvertViewController }) + (colorInvertController as? ColorInvertViewController)?.originalImagePost = self.originalImagePost + + + + + + } + } + } + - @IBOutlet weak var editPhoto: UIButton! - @IBOutlet weak var imageView: UIImageView! - @IBOutlet weak var titleTextField: UITextField! - @IBOutlet weak var chooseImageButton: UIButton! - @IBOutlet weak var imageHeightConstraint: NSLayoutConstraint! - @IBOutlet weak var postButton: UIBarButtonItem! } extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { - + chooseImageButton.setTitle("", for: []) picker.dismiss(animated: true, completion: nil) @@ -161,12 +188,12 @@ extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigation guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } imageView.image = image - - ImagePostViewController.sharedPhoto = image - - editPhoto.setTitleColor(.systemBlue, for: .normal) + originalImagePost = image setImageViewHeight(with: image.ratio) + + editPhoto.setTitleColor(.systemBlue, for: .normal) + } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { From 90d76c83d97fa5f27520c6a1f170a5b39450da76 Mon Sep 17 00:00:00 2001 From: morgan smith Date: Mon, 7 Sep 2020 20:17:32 -0400 Subject: [PATCH 4/7] added audio branch --- LambdaTimeline/View Controllers/ImagePostViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index 8a1abecf..1a43f1f3 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -10,6 +10,7 @@ import UIKit import CoreImage import Photos + class ImagePostViewController: ShiftableViewController { @IBOutlet weak var editPhoto: UIButton! From 01d84f07d15fc710c550b6cec98ecc589a36220c Mon Sep 17 00:00:00 2001 From: morgan smith Date: Mon, 7 Sep 2020 21:21:40 -0400 Subject: [PATCH 5/7] added audio comments file --- .../audioComments.xcodeproj/project.pbxproj | 351 ++++++++++++++++++ audioComments/audioComments/AppDelegate.swift | 37 ++ .../AppIcon.appiconset/Contents.json | 98 +++++ .../Assets.xcassets/Contents.json | 6 + .../AudioRecorderViewController.swift | 317 ++++++++++++++++ .../audioComments/AudioVisualizer.swift | 276 ++++++++++++++ .../Base.lproj/LaunchScreen.storyboard | 25 ++ .../audioComments/Base.lproj/Main.storyboard | 117 ++++++ audioComments/audioComments/Info.plist | 66 ++++ .../audioComments/SceneDelegate.swift | 53 +++ audioComments/audioComments/piano.mp3 | Bin 0 -> 101760 bytes 11 files changed, 1346 insertions(+) create mode 100644 audioComments/audioComments.xcodeproj/project.pbxproj create mode 100644 audioComments/audioComments/AppDelegate.swift create mode 100644 audioComments/audioComments/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 audioComments/audioComments/Assets.xcassets/Contents.json create mode 100644 audioComments/audioComments/AudioRecorderViewController.swift create mode 100644 audioComments/audioComments/AudioVisualizer.swift create mode 100644 audioComments/audioComments/Base.lproj/LaunchScreen.storyboard create mode 100644 audioComments/audioComments/Base.lproj/Main.storyboard create mode 100644 audioComments/audioComments/Info.plist create mode 100644 audioComments/audioComments/SceneDelegate.swift create mode 100644 audioComments/audioComments/piano.mp3 diff --git a/audioComments/audioComments.xcodeproj/project.pbxproj b/audioComments/audioComments.xcodeproj/project.pbxproj new file mode 100644 index 00000000..d656b30f --- /dev/null +++ b/audioComments/audioComments.xcodeproj/project.pbxproj @@ -0,0 +1,351 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 02DBEB542507093800EDFB45 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB532507093800EDFB45 /* AppDelegate.swift */; }; + 02DBEB562507093800EDFB45 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB552507093800EDFB45 /* SceneDelegate.swift */; }; + 02DBEB5B2507093800EDFB45 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 02DBEB592507093800EDFB45 /* Main.storyboard */; }; + 02DBEB5D2507093D00EDFB45 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02DBEB5C2507093D00EDFB45 /* Assets.xcassets */; }; + 02DBEB602507093D00EDFB45 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 02DBEB5E2507093D00EDFB45 /* LaunchScreen.storyboard */; }; + 02DBEB68250709C100EDFB45 /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB67250709C100EDFB45 /* AudioVisualizer.swift */; }; + 02DBEB6A250709DD00EDFB45 /* AudioRecorderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB69250709DD00EDFB45 /* AudioRecorderViewController.swift */; }; + 02DBEB6C25070FBA00EDFB45 /* piano.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 02DBEB6B25070FBA00EDFB45 /* piano.mp3 */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 02DBEB502507093800EDFB45 /* audioComments.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = audioComments.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 02DBEB532507093800EDFB45 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 02DBEB552507093800EDFB45 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 02DBEB5A2507093800EDFB45 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 02DBEB5C2507093D00EDFB45 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 02DBEB5F2507093D00EDFB45 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 02DBEB612507093D00EDFB45 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 02DBEB67250709C100EDFB45 /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; + 02DBEB69250709DD00EDFB45 /* AudioRecorderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderViewController.swift; sourceTree = ""; }; + 02DBEB6B25070FBA00EDFB45 /* piano.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = piano.mp3; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 02DBEB4D2507093800EDFB45 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 02DBEB472507093800EDFB45 = { + isa = PBXGroup; + children = ( + 02DBEB522507093800EDFB45 /* audioComments */, + 02DBEB512507093800EDFB45 /* Products */, + ); + sourceTree = ""; + }; + 02DBEB512507093800EDFB45 /* Products */ = { + isa = PBXGroup; + children = ( + 02DBEB502507093800EDFB45 /* audioComments.app */, + ); + name = Products; + sourceTree = ""; + }; + 02DBEB522507093800EDFB45 /* audioComments */ = { + isa = PBXGroup; + children = ( + 02DBEB6B25070FBA00EDFB45 /* piano.mp3 */, + 02DBEB532507093800EDFB45 /* AppDelegate.swift */, + 02DBEB552507093800EDFB45 /* SceneDelegate.swift */, + 02DBEB592507093800EDFB45 /* Main.storyboard */, + 02DBEB5C2507093D00EDFB45 /* Assets.xcassets */, + 02DBEB5E2507093D00EDFB45 /* LaunchScreen.storyboard */, + 02DBEB612507093D00EDFB45 /* Info.plist */, + 02DBEB67250709C100EDFB45 /* AudioVisualizer.swift */, + 02DBEB69250709DD00EDFB45 /* AudioRecorderViewController.swift */, + ); + path = audioComments; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 02DBEB4F2507093800EDFB45 /* audioComments */ = { + isa = PBXNativeTarget; + buildConfigurationList = 02DBEB642507093D00EDFB45 /* Build configuration list for PBXNativeTarget "audioComments" */; + buildPhases = ( + 02DBEB4C2507093800EDFB45 /* Sources */, + 02DBEB4D2507093800EDFB45 /* Frameworks */, + 02DBEB4E2507093800EDFB45 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = audioComments; + productName = audioComments; + productReference = 02DBEB502507093800EDFB45 /* audioComments.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 02DBEB482507093800EDFB45 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1150; + LastUpgradeCheck = 1150; + ORGANIZATIONNAME = "Morgan Smith"; + TargetAttributes = { + 02DBEB4F2507093800EDFB45 = { + CreatedOnToolsVersion = 11.5; + }; + }; + }; + buildConfigurationList = 02DBEB4B2507093800EDFB45 /* Build configuration list for PBXProject "audioComments" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 02DBEB472507093800EDFB45; + productRefGroup = 02DBEB512507093800EDFB45 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 02DBEB4F2507093800EDFB45 /* audioComments */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 02DBEB4E2507093800EDFB45 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02DBEB602507093D00EDFB45 /* LaunchScreen.storyboard in Resources */, + 02DBEB5D2507093D00EDFB45 /* Assets.xcassets in Resources */, + 02DBEB6C25070FBA00EDFB45 /* piano.mp3 in Resources */, + 02DBEB5B2507093800EDFB45 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 02DBEB4C2507093800EDFB45 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02DBEB542507093800EDFB45 /* AppDelegate.swift in Sources */, + 02DBEB6A250709DD00EDFB45 /* AudioRecorderViewController.swift in Sources */, + 02DBEB68250709C100EDFB45 /* AudioVisualizer.swift in Sources */, + 02DBEB562507093800EDFB45 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 02DBEB592507093800EDFB45 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 02DBEB5A2507093800EDFB45 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 02DBEB5E2507093D00EDFB45 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 02DBEB5F2507093D00EDFB45 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 02DBEB622507093D00EDFB45 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 02DBEB632507093D00EDFB45 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.5; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 02DBEB652507093D00EDFB45 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QQ666D93S6; + INFOPLIST_FILE = audioComments/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.morgansmith.audioComments; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 02DBEB662507093D00EDFB45 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = QQ666D93S6; + INFOPLIST_FILE = audioComments/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.morgansmith.audioComments; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 02DBEB4B2507093800EDFB45 /* Build configuration list for PBXProject "audioComments" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02DBEB622507093D00EDFB45 /* Debug */, + 02DBEB632507093D00EDFB45 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 02DBEB642507093D00EDFB45 /* Build configuration list for PBXNativeTarget "audioComments" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 02DBEB652507093D00EDFB45 /* Debug */, + 02DBEB662507093D00EDFB45 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 02DBEB482507093800EDFB45 /* Project object */; +} diff --git a/audioComments/audioComments/AppDelegate.swift b/audioComments/audioComments/AppDelegate.swift new file mode 100644 index 00000000..94384d88 --- /dev/null +++ b/audioComments/audioComments/AppDelegate.swift @@ -0,0 +1,37 @@ +// +// AppDelegate.swift +// audioComments +// +// Created by Morgan Smith on 9/7/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/audioComments/audioComments/Assets.xcassets/AppIcon.appiconset/Contents.json b/audioComments/audioComments/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/audioComments/audioComments/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/audioComments/audioComments/Assets.xcassets/Contents.json b/audioComments/audioComments/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/audioComments/audioComments/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/audioComments/audioComments/AudioRecorderViewController.swift b/audioComments/audioComments/AudioRecorderViewController.swift new file mode 100644 index 00000000..1cf559d9 --- /dev/null +++ b/audioComments/audioComments/AudioRecorderViewController.swift @@ -0,0 +1,317 @@ +// +// AudioRecorderViewController.swift +// audioComments +// +// Created by Morgan Smith on 9/7/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import AVFoundation + +class AudioRecorderController: UIViewController { + + var audioPlayer: AVAudioPlayer? { + didSet { + guard let audioPlayer = audioPlayer else { + return + } + audioPlayer.delegate = self + audioPlayer.isMeteringEnabled = true + updateViews() + } + } + + weak var timer: Timer? + + var recordingURL: URL? + var audioRecorder: AVAudioRecorder? + + @IBOutlet var playButton: UIButton! + @IBOutlet var recordButton: UIButton! + @IBOutlet var timeElapsedLabel: UILabel! + @IBOutlet var timeRemainingLabel: UILabel! + @IBOutlet var timeSlider: UISlider! + @IBOutlet var audioVisualizer: AudioVisualizer! + + private lazy var timeIntervalFormatter: DateComponentsFormatter = { + // NOTE: DateComponentFormatter is good for minutes/hours/seconds + // DateComponentsFormatter is not good for milliseconds, use DateFormatter instead) + + let formatting = DateComponentsFormatter() + formatting.unitsStyle = .positional // 00:00 mm:ss + formatting.zeroFormattingBehavior = .pad + formatting.allowedUnits = [.minute, .second] + return formatting + }() + + + // MARK: - View Controller Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Use a font that won't jump around as values change + timeElapsedLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeElapsedLabel.font.pointSize, + weight: .regular) + timeRemainingLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeRemainingLabel.font.pointSize, + weight: .regular) + + loadAudio() + updateViews() + } + func updateViews() { + playButton.isEnabled = !isRecording + recordButton.isEnabled = !isPlaying + timeSlider.isEnabled = !isRecording + playButton.isSelected = isPlaying + recordButton.isSelected = isRecording + if !isRecording { + let elapsedTime = audioPlayer?.currentTime ?? 0 + let duration = audioPlayer?.duration ?? 0 + let timeRemaining = duration.rounded() - elapsedTime + timeElapsedLabel.text = timeIntervalFormatter.string(from: elapsedTime) + timeSlider.minimumValue = 0 + timeSlider.maximumValue = Float(duration) + timeSlider.value = Float(elapsedTime) + timeRemainingLabel.text = "-" + timeIntervalFormatter.string(from: timeRemaining)! + } else { + let elapsedTime = audioRecorder?.currentTime ?? 0 + timeElapsedLabel.text = "--:--" + timeSlider.minimumValue = 0 + timeSlider.maximumValue = 1 + timeSlider.value = 0 + timeRemainingLabel.text = timeIntervalFormatter.string(from: elapsedTime) + } + } + + + deinit { + timer?.invalidate() + } + + + // MARK: - Timer + + + func startTimer() { + timer?.invalidate() + + // time interval always in seconds + //whenever have @escaping block, block that sticks around, be very careful about usage of self because it takes up memory... which is why use weak self. + // change the withTimeInterval to change the refresh display, generally dont go below 30 miliseconds + timer = Timer.scheduledTimer(withTimeInterval: 0.030, repeats: true) { [weak self] (_) in + //guard let deals with escaping blocks, so return before do anything + guard let self = self else { return } + + self.updateViews() + + if let audioRecorder = self.audioRecorder, + self.isRecording == true { + + audioRecorder.updateMeters() + self.audioVisualizer.addValue(decibelValue: audioRecorder.averagePower(forChannel: 0)) + + } + + if let audioPlayer = self.audioPlayer, + self.isPlaying == true { + + audioPlayer.updateMeters() + self.audioVisualizer.addValue(decibelValue: audioPlayer.averagePower(forChannel: 0)) + } + } + } + + func cancelTimer() { + //invalidate doesnt set property to nil, but keeps the timer from running + timer?.invalidate() + timer = nil + } + + + + // MARK: - Playback + + var isPlaying: Bool { + audioPlayer?.isPlaying ?? false + } + + func loadAudio() { + let songURL = Bundle.main.url(forResource: "piano", withExtension: "mp3")! + + do { + audioPlayer = try AVAudioPlayer(contentsOf: songURL) + } catch { + preconditionFailure("Failure to load audio file: \(error)") + } + } + + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) // can fail if on a phone call, for instance + } + + + func play() { + do { + try prepareAudioSession() + audioPlayer?.play() + updateViews() + startTimer() + } catch { + print("Cannot play audio: \(error)") + } + + + } + + func pause() { + audioPlayer?.pause() + updateViews() + cancelTimer() + } + + + // MARK: - Recording + + var isRecording: Bool { + audioRecorder?.isRecording ?? false + } + + func createNewRecordingURL() -> URL { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + let name = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: .withInternetDateTime) + let file = documents.appendingPathComponent(name, isDirectory: false).appendingPathExtension("caf") + + print("recording URL: \(file)") + + return file + } + + + func requestPermissionOrStartRecording() { + switch AVAudioSession.sharedInstance().recordPermission { + case .undetermined: + AVAudioSession.sharedInstance().requestRecordPermission { granted in + guard granted == true else { + print("We need microphone access") + return + } + + print("Recording permission has been granted!") + // NOTE: Invite the user to tap record again, since we just interrupted them, and they may not have been ready to record + } + case .denied: + print("Microphone access has been blocked.") + + let alertController = UIAlertController(title: "Microphone Access Denied", message: "Please allow this app to access your Microphone.", preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: "Open Settings", style: .default) { (_) in + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + }) + + alertController.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alertController, animated: true, completion: nil) + case .granted: + startRecording() + @unknown default: + break + } + } + + + func startRecording() { + do { + try prepareAudioSession() + } catch { + print("Cannot record audio: \(error)") + return + } + + recordingURL = createNewRecordingURL() + + //can return option because you can give it invalid format and combinations + let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)! + + do { + audioRecorder = try AVAudioRecorder(url: recordingURL!, format: format) + audioRecorder?.delegate = self + audioRecorder?.isMeteringEnabled = true + audioRecorder?.record() + updateViews() + startTimer() + } catch { + preconditionFailure("The audio recorder could not be created with \(recordingURL!) and \(format)") + } + + } + + func stopRecording() { + audioRecorder?.stop() + updateViews() + cancelTimer() + } + + // MARK: - Actions + + @IBAction func togglePlayback(_ sender: Any) { + if isPlaying { + pause() + } else { + play() + } + + } + + @IBAction func updateCurrentTime(_ sender: UISlider) { + if isPlaying { + pause() + } + audioPlayer?.currentTime = TimeInterval(sender.value) + updateViews() + } + + @IBAction func toggleRecording(_ sender: Any) { + if isRecording { + stopRecording() + } else { + requestPermissionOrStartRecording() + } + } +} + +extension AudioRecorderController: AVAudioPlayerDelegate { + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + updateViews() + cancelTimer() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error = error { + print("Audio Player Error: \(error)") + } + } +} + +extension AudioRecorderController: AVAudioRecorderDelegate { + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + if let recordingURL = recordingURL { + audioPlayer = try? AVAudioPlayer(contentsOf: recordingURL) + } + recordingURL = nil + + } + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + if let error = error { + print("Audio Recorder Error: \(error)") + } + } +} + + diff --git a/audioComments/audioComments/AudioVisualizer.swift b/audioComments/audioComments/AudioVisualizer.swift new file mode 100644 index 00000000..56b7dc33 --- /dev/null +++ b/audioComments/audioComments/AudioVisualizer.swift @@ -0,0 +1,276 @@ +// +// AudioVisualizer.swift +// audioComments +// +// Created by Morgan Smith on 9/7/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit + +@IBDesignable +public class AudioVisualizer: UIView { + + // MARK: IBInspectable Properties + + /// The width of a bar in points. + @IBInspectable public var barWidth: CGFloat = 10 { + didSet { + updateBars() + } + } + + /// The corner radius of a bar in points. If less than `0`, then it will default to half of the width of the bar. + @IBInspectable public var barCornerRadius: CGFloat = -1 { + didSet { + updateBars() + } + } + + /// The spacing between bars in points. + @IBInspectable public var barSpacing: CGFloat = 4 { + didSet { + updateBars() + } + } + + /// The color of a bar. + @IBInspectable public var barColor: UIColor = .systemGray { + didSet { + for bar in bars { + bar.backgroundColor = barColor + } + } + } + + /// The amount of time before a bar decays into the adjacent spot + @IBInspectable public var decaySpeed: Double = 0.01 { + didSet { + decayTimer?.invalidate() + decayTimer = nil + } + } + + /// The fraction the newest value will decay by if not updated by the time a decay starts + @IBInspectable public var decayAmount: Double = 0.8 + + // MARK: Internal Properties + + private var bars = [UIView]() + private var values = [Double]() + + private weak var decayTimer: Timer? + private var newestValue: Double = 0 + + // MARK: - Object Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + + initialSetup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + initialSetup() + } + + func initialSetup() { + // Pre-fill values for Interface Builder preview + #if TARGET_INTERFACE_BUILDER + values = [ + 0.19767167952644904, + 0.30975147553721694, + 0.2717680681330001, + 0.25914398105158504, + 0.3413322535900626, + 0.311223010327166, + 0.3302641160440099, + 0.303853272136915, + 0.2659123465612464, + 0.2860924489760262, + 0.26477145407733543, + 0.23180693200970012, + 0.24445487891619533, + 0.21484121767935302, + 0.19688917099112885, + 0.19020094289324854, + 0.17402194532721785, + 0.1600055988294578, + 0.15120753744055154, + 0.13789741397752767, + 0.13231033268544698, + 0.1270923459375989, + 0.1121238175344413, + 0.12400069790665748, + 0.24978783142512598, + 0.233063298365594, + 0.5375441947045457, + 0.47456518731446534, + 0.5236630241490436, + 0.4692151822551929, + 0.4255172022748686, + 0.46023063710569184, + 0.42934908823397355, + 0.37221041959882545, + 0.4685050055667653, + 0.4209394065681193, + 0.46643118034506187, + 0.4292307341708633, + 0.3814422662003417, + 0.4386719969186142, + 0.3956598546828729 + ] + #endif + + // Build the inner bars + self.updateBars() + } + + deinit { + // Invalidate the timer if it is still active + decayTimer?.invalidate() + } + + // MARK: - Layout + override public func layoutSubviews() { + updateBars() + } + + private func updateBars() { + // Clean up old bars + for bar in bars { + bar.removeFromSuperview() + } + + var newBars = [UIView]() + + + // Make sure the width of a bar and spacing is greater than 0, and that the available width is also greater than 0 + guard round(barWidth) > 0, barSpacing >= 0, bounds.width > 0, bounds.height > 0 else { + // Not enough information to create a single bar, so bail early + bars = [] + return + } + + // Calculate number of bars we will be able to display + var numberOfBarsToCreate = Int(bounds.width/(barWidth + barSpacing)) + + // Helper function for creating bars + func createBar(_ positionFromCenter: Int) { + let bar = UIView(frame: frame(forBar: positionFromCenter)) + bar.backgroundColor = barColor + bar.layer.cornerRadius = (barCornerRadius < 0 || barCornerRadius > barWidth/2) ? floor(barWidth/3) : barCornerRadius + + numberOfBarsToCreate -= 1 + newBars.append(bar) + self.addSubview(bar) + } + + // Create the center bar + createBar(0) + + // Keep creating bars in pairs until there is no more room + var position = 1 + while numberOfBarsToCreate > 0 { + // Create the symmetric pairs of bars starting from the center + createBar(-position) + createBar(position) + + position += 1 + } + + bars = newBars + } + + /// Calculate the frame of a particular bar + /// - Parameter positionFromCenter: The distance of the bar from the center (which is 0) + private func frame(forBar positionFromCenter: Int) -> CGRect { + let valueIndex = Int(positionFromCenter.magnitude) + + return frame(forBar: positionFromCenter, value: (valueIndex < values.count) ? values[valueIndex] : 0) + } + + /// Calculate the frame of a particular bar, specifying a value + /// - Parameter positionFromCenter: The distance of the bar from the center (which is 0) + private func frame(forBar positionFromCenter: Int, value: Double) -> CGRect { + let maxValue = (1 - CGFloat(positionFromCenter.magnitude)*(barWidth + barSpacing)/bounds.width/2)*bounds.height/2 + let height = CGFloat(value)*maxValue + + return CGRect(x: floor(bounds.width/2) + CGFloat(positionFromCenter)*(barWidth + barSpacing) - barWidth/2, y: floor(bounds.height/2) - height, width: barWidth, height: height*2) + } + + // MARK: - Animation + + /// Start the decay timer, but only if if hasn't been created yet + private func startTimer() { + guard decayTimer == nil else { return } + + decayTimer = Timer.scheduledTimer(withTimeInterval: decaySpeed, repeats: true) { [weak self] (_) in + guard let self = self else { return } + + self.decayNewestValue() + } + } + + private func decayNewestValue() { + values.insert(newestValue, at: 0) + + // Trim the end of the values array if there are too many for the number of bars + let currentCount = values.count + let maxCount = (bars.count + 1)/2 + /* + Note that the amount of bars will always be either 0, or an odd number (since the bars are counted in pairs after the first central bar), so we chose a "transformation" (a mathematical function) that satisfies this: value index = floor((bar index + 1)/2) + + Bar index: 0 1 2 3 4 5 6 7 8 9 ... + (valid bar index): 0 1 - 3 - 5 - 7 - 9 ... + Value index: 0 1 1 2 2 3 3 4 4 5 ... + + */ + if currentCount > maxCount { + values.removeSubrange(maxCount ..< currentCount) + } + + // Update the frames of each bar + for (positionFromCenter, value) in values.enumerated() { + if positionFromCenter == 0 { + bars[0].frame = frame(forBar: positionFromCenter, value: value) + } else { + bars[positionFromCenter*2 - 1].frame = frame(forBar: -positionFromCenter, value: value) + bars[positionFromCenter*2].frame = frame(forBar: positionFromCenter, value: value) + } + } + + // Decay the newest value + newestValue = newestValue*decayAmount + + // Check if the values are empty + let totalValue = values.reduce(0.0) { $0 + $1 } + if totalValue <= 0.000001 { + // Note that total value may never reach 0, but this is small enough to clear everything out + decayTimer?.invalidate() + decayTimer = nil + } + } + + // MARK: - Public Methods + + /// Add a value to the visualizer. Be sure to call `AVAudioPlayer.isMeteringEnabled = true`, and `AVAudioPlayer.updateMeters()` before every call to `AVAudioPlayer.averagePower(forChannel: 0)` + /// - Parameter decibelValue: The value you would get out of `AVAudioPlayer.averagePower(forChannel: 0)` + public func addValue(decibelValue: Float) { + addValue(decibelValue: Double(decibelValue)) + } + + /// Add a value to the visualizer. Be sure to call `AVAudioPlayer.isMeteringEnabled = true`, and `AVAudioPlayer.updateMeters()` before every call to `AVAudioPlayer.averagePower(forChannel: 0)` + /// - Parameter decibelValue: The value you would get out of `AVAudioPlayer.averagePower(forChannel: 0)` + public func addValue(decibelValue: Double) { + let normalizedValue = __exp10(decibelValue/20) + + newestValue = normalizedValue + + startTimer() + } + +} + diff --git a/audioComments/audioComments/Base.lproj/LaunchScreen.storyboard b/audioComments/audioComments/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/audioComments/audioComments/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/audioComments/audioComments/Base.lproj/Main.storyboard b/audioComments/audioComments/Base.lproj/Main.storyboard new file mode 100644 index 00000000..32366385 --- /dev/null +++ b/audioComments/audioComments/Base.lproj/Main.storyboard @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/audioComments/audioComments/Info.plist b/audioComments/audioComments/Info.plist new file mode 100644 index 00000000..7da6c2db --- /dev/null +++ b/audioComments/audioComments/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSMicrophoneUsageDescription + $(PRODUCT_NAME) access to recording privacy + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/audioComments/audioComments/SceneDelegate.swift b/audioComments/audioComments/SceneDelegate.swift new file mode 100644 index 00000000..3b7851e8 --- /dev/null +++ b/audioComments/audioComments/SceneDelegate.swift @@ -0,0 +1,53 @@ +// +// SceneDelegate.swift +// audioComments +// +// Created by Morgan Smith on 9/7/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/audioComments/audioComments/piano.mp3 b/audioComments/audioComments/piano.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..87ed0dbcdcfc96f3a5ffdf71c0dd5f6bdb402c8b GIT binary patch literal 101760 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 TN)R;o^ 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 literal 0 HcmV?d00001 From ef8ad0522d8826dfc32c8732e36c37c724a93c63 Mon Sep 17 00:00:00 2001 From: morgan smith Date: Tue, 8 Sep 2020 21:22:11 -0400 Subject: [PATCH 6/7] finished audio comments subfile --- .../audioComments.xcodeproj/project.pbxproj | 8 + .../AudioRecorderViewController.swift | 19 ++- .../audioComments/Base.lproj/Main.storyboard | 148 +++++++++++++++--- .../RecordedCommentTableViewCell.swift | 113 +++++++++++++ .../RecordedCommentTableViewController.swift | 75 +++++++++ 5 files changed, 337 insertions(+), 26 deletions(-) create mode 100644 audioComments/audioComments/RecordedCommentTableViewCell.swift create mode 100644 audioComments/audioComments/RecordedCommentTableViewController.swift diff --git a/audioComments/audioComments.xcodeproj/project.pbxproj b/audioComments/audioComments.xcodeproj/project.pbxproj index d656b30f..ea0fa0cf 100644 --- a/audioComments/audioComments.xcodeproj/project.pbxproj +++ b/audioComments/audioComments.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 02DBEB68250709C100EDFB45 /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB67250709C100EDFB45 /* AudioVisualizer.swift */; }; 02DBEB6A250709DD00EDFB45 /* AudioRecorderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB69250709DD00EDFB45 /* AudioRecorderViewController.swift */; }; 02DBEB6C25070FBA00EDFB45 /* piano.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 02DBEB6B25070FBA00EDFB45 /* piano.mp3 */; }; + 02DBEB6E25083B8300EDFB45 /* RecordedCommentTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB6D25083B8300EDFB45 /* RecordedCommentTableViewController.swift */; }; + 02DBEB7025083B9900EDFB45 /* RecordedCommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02DBEB6F25083B9900EDFB45 /* RecordedCommentTableViewCell.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,6 +30,8 @@ 02DBEB67250709C100EDFB45 /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; 02DBEB69250709DD00EDFB45 /* AudioRecorderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderViewController.swift; sourceTree = ""; }; 02DBEB6B25070FBA00EDFB45 /* piano.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = piano.mp3; sourceTree = ""; }; + 02DBEB6D25083B8300EDFB45 /* RecordedCommentTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedCommentTableViewController.swift; sourceTree = ""; }; + 02DBEB6F25083B9900EDFB45 /* RecordedCommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordedCommentTableViewCell.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,6 +73,8 @@ 02DBEB612507093D00EDFB45 /* Info.plist */, 02DBEB67250709C100EDFB45 /* AudioVisualizer.swift */, 02DBEB69250709DD00EDFB45 /* AudioRecorderViewController.swift */, + 02DBEB6D25083B8300EDFB45 /* RecordedCommentTableViewController.swift */, + 02DBEB6F25083B9900EDFB45 /* RecordedCommentTableViewCell.swift */, ); path = audioComments; sourceTree = ""; @@ -145,8 +151,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 02DBEB6E25083B8300EDFB45 /* RecordedCommentTableViewController.swift in Sources */, 02DBEB542507093800EDFB45 /* AppDelegate.swift in Sources */, 02DBEB6A250709DD00EDFB45 /* AudioRecorderViewController.swift in Sources */, + 02DBEB7025083B9900EDFB45 /* RecordedCommentTableViewCell.swift in Sources */, 02DBEB68250709C100EDFB45 /* AudioVisualizer.swift in Sources */, 02DBEB562507093800EDFB45 /* SceneDelegate.swift in Sources */, ); diff --git a/audioComments/audioComments/AudioRecorderViewController.swift b/audioComments/audioComments/AudioRecorderViewController.swift index 1cf559d9..93b477d5 100644 --- a/audioComments/audioComments/AudioRecorderViewController.swift +++ b/audioComments/audioComments/AudioRecorderViewController.swift @@ -25,6 +25,8 @@ class AudioRecorderController: UIViewController { weak var timer: Timer? var recordingURL: URL? + var savedRecording: URL? + var audioRecorder: AVAudioRecorder? @IBOutlet var playButton: UIButton! @@ -188,7 +190,6 @@ class AudioRecorderController: UIViewController { let file = documents.appendingPathComponent(name, isDirectory: false).appendingPathExtension("caf") print("recording URL: \(file)") - return file } @@ -236,7 +237,8 @@ class AudioRecorderController: UIViewController { recordingURL = createNewRecordingURL() //can return option because you can give it invalid format and combinations - let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)! + // let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)! + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44_100, channels: 1, interleaved: false)! do { audioRecorder = try AVAudioRecorder(url: recordingURL!, format: format) @@ -283,6 +285,18 @@ class AudioRecorderController: UIViewController { requestPermissionOrStartRecording() } } + + @IBAction func saveRecording(_ sender: Any) { + + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "saveRecording" { + if let tableVC = segue.destination as? RecordedCommentTableViewController, let recording = savedRecording { + tableVC.audioRecording = recording + } + } + } } extension AudioRecorderController: AVAudioPlayerDelegate { @@ -303,6 +317,7 @@ extension AudioRecorderController: AVAudioRecorderDelegate { func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { if let recordingURL = recordingURL { audioPlayer = try? AVAudioPlayer(contentsOf: recordingURL) + savedRecording = recordingURL } recordingURL = nil diff --git a/audioComments/audioComments/Base.lproj/Main.storyboard b/audioComments/audioComments/Base.lproj/Main.storyboard index 32366385..d8b489d3 100644 --- a/audioComments/audioComments/Base.lproj/Main.storyboard +++ b/audioComments/audioComments/Base.lproj/Main.storyboard @@ -16,30 +16,14 @@ - + + + + - - - - - - - - + + + + + + + + + - - + + + - + @@ -91,6 +103,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -112,6 +211,7 @@ + diff --git a/audioComments/audioComments/RecordedCommentTableViewCell.swift b/audioComments/audioComments/RecordedCommentTableViewCell.swift new file mode 100644 index 00000000..2e002e3a --- /dev/null +++ b/audioComments/audioComments/RecordedCommentTableViewCell.swift @@ -0,0 +1,113 @@ +// +// RecordedCommentTableViewCell.swift +// audioComments +// +// Created by Morgan Smith on 9/8/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import AVFoundation + +class RecordedCommentTableViewCell: UITableViewCell { + + override func awakeFromNib() { + super.awakeFromNib() + } + + var audioPlayer: AVAudioPlayer? { + didSet { + guard let audioPlayer = audioPlayer else { + return + } + audioPlayer.delegate = self + updateViews() + } + } + + var recordingURL: URL? { + didSet { + loadAudio() + updateViews() + } + } + + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var timeElapsedLabel: UILabel! + @IBOutlet weak var timeRemainingLabel: UILabel! + @IBOutlet weak var playButton: UIButton! + @IBOutlet weak var timeSlider: UISlider! + + + + func updateViews() { + playButton.isSelected = isPlaying + } + + + + + var isPlaying: Bool { + audioPlayer?.isPlaying ?? false + } + + + + func loadAudio() { + guard let songURL = recordingURL else { return } + + do { + audioPlayer = try AVAudioPlayer(contentsOf: songURL) + } catch { + preconditionFailure("Failure to load audio file: \(error)") + } + } + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) // can fail if on a phone call, for instance + } + + func play() { + do { + try prepareAudioSession() + audioPlayer?.play() + updateViews() + } catch { + print("Cannot play audio: \(error)") + } + + + } + + func pause() { + audioPlayer?.pause() + updateViews() + } + + @IBAction func togglePlayback(_ sender: Any) { + if isPlaying { + pause() + } else { + play() + } + } + + +} + + +extension RecordedCommentTableViewCell: AVAudioPlayerDelegate { + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + updateViews() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error = error { + print("Audio Player Error: \(error)") + } + } +} + diff --git a/audioComments/audioComments/RecordedCommentTableViewController.swift b/audioComments/audioComments/RecordedCommentTableViewController.swift new file mode 100644 index 00000000..8c0aabef --- /dev/null +++ b/audioComments/audioComments/RecordedCommentTableViewController.swift @@ -0,0 +1,75 @@ +// +// RecordedCommentTableViewController.swift +// audioComments +// +// Created by Morgan Smith on 9/8/20. +// Copyright © 2020 Morgan Smith. All rights reserved. +// + +import UIKit +import AVFoundation + +class RecordedCommentTableViewController: UITableViewController { + + var audioRecordings: [URL] = [] { + didSet { + tableView.reloadData() + print("table view recordings: \(audioRecordings)") + } + } + + var audioRecording: URL? + + override func viewDidLoad() { + super.viewDidLoad() + if let audioRecording = audioRecording { + audioRecordings.append(audioRecording) + } + tableView.dataSource = self + tableView.delegate = self + } + + // MARK: - Table view data source + + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return audioRecordings.count + } + + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: "audioCommentCell", for: indexPath) as? RecordedCommentTableViewCell else { return UITableViewCell() } + let recording = audioRecordings[indexPath.row] + + cell.recordingURL = recording + cell.playButton.isSelected = false + return cell + } + + + + + // Override to support editing the table view. + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + let recordingToDelete = audioRecordings[indexPath.row] + audioRecordings.removeAll { $0 == recordingToDelete } + tableView.deleteRows(at: [indexPath], with: .fade) + tableView.reloadData() + } + } + + + + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + +} From 7c736c0f845b611bc5ea5ea6d194cf1bc1c018fa Mon Sep 17 00:00:00 2001 From: morgan smith Date: Wed, 9 Sep 2020 16:26:00 -0400 Subject: [PATCH 7/7] working on audio comments --- LambdaTimeline.xcodeproj/project.pbxproj | 12 + LambdaTimeline/GoogleService-Info.plist | 14 +- LambdaTimeline/Helpers/AudioVisualizer.swift | 277 +++++++++++++++ .../Model Controllers/PostController.swift | 17 +- LambdaTimeline/Models/Comment.swift | 12 +- LambdaTimeline/Models/Post.swift | 3 +- LambdaTimeline/Resources/Info.plist | 4 + .../Storyboards/Base.lproj/Main.storyboard | 179 ++++++++-- .../AudioViewController.swift | 326 ++++++++++++++++++ .../ImagePostDetailTableViewController.swift | 72 +++- .../ImagePostViewController.swift | 6 +- .../Views/CommentTableViewCell.swift | 121 +++++++ .../Views/ImagePostCollectionViewCell.swift | 2 +- 13 files changed, 987 insertions(+), 58 deletions(-) create mode 100644 LambdaTimeline/Helpers/AudioVisualizer.swift create mode 100644 LambdaTimeline/View Controllers/AudioViewController.swift create mode 100644 LambdaTimeline/Views/CommentTableViewCell.swift diff --git a/LambdaTimeline.xcodeproj/project.pbxproj b/LambdaTimeline.xcodeproj/project.pbxproj index baa9bef8..20f0d028 100644 --- a/LambdaTimeline.xcodeproj/project.pbxproj +++ b/LambdaTimeline.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 02C7B5812501850F00C4A310 /* ColorInvertViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */; }; 02C7B5832501857600C4A310 /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */; }; 02C7B5852501B54600C4A310 /* CIImage+JPEG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B5842501B54600C4A310 /* CIImage+JPEG.swift */; }; + 02F737DC250872C100C0E9AD /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F737DB250872C100C0E9AD /* AudioVisualizer.swift */; }; + 02F737DE2508736500C0E9AD /* AudioViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F737DD2508736500C0E9AD /* AudioViewController.swift */; }; + 02F737E0250883AD00C0E9AD /* CommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F737DF250883AD00C0E9AD /* CommentTableViewCell.swift */; }; 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */; }; 46463781216FDE4B00E7FF73 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4646377F216FDE4B00E7FF73 /* Main.storyboard */; }; 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46463782216FDE4C00E7FF73 /* Assets.xcassets */; }; @@ -49,6 +52,9 @@ 02C7B5802501850F00C4A310 /* ColorInvertViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInvertViewController.swift; sourceTree = ""; }; 02C7B5822501857600C4A310 /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; 02C7B5842501B54600C4A310 /* CIImage+JPEG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CIImage+JPEG.swift"; sourceTree = ""; }; + 02F737DB250872C100C0E9AD /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; + 02F737DD2508736500C0E9AD /* AudioViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioViewController.swift; sourceTree = ""; }; + 02F737DF250883AD00C0E9AD /* CommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentTableViewCell.swift; sourceTree = ""; }; 33A2B89DE9E21538434BD640 /* Pods-LambdaTimeline.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LambdaTimeline.debug.xcconfig"; path = "Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline.debug.xcconfig"; sourceTree = ""; }; 46463778216FDE4B00E7FF73 /* LambdaTimeline.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LambdaTimeline.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -189,6 +195,7 @@ 46A0366921700F5100E7FF73 /* PostsCollectionViewController.swift */, 46CFE6F421707D0000E7FF73 /* ImagePostViewController.swift */, 46D571F72173FC2700E7FF73 /* ImagePostDetailTableViewController.swift */, + 02F737DD2508736500C0E9AD /* AudioViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -197,6 +204,7 @@ isa = PBXGroup; children = ( 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */, + 02F737DF250883AD00C0E9AD /* CommentTableViewCell.swift */, ); path = Views; sourceTree = ""; @@ -219,6 +227,7 @@ 464637982170048900E7FF73 /* FirebaseConvertible.swift */, 46CFE6FC217154F500E7FF73 /* Networking.swift */, 46CFE6F82170862C00E7FF73 /* ShiftableViewController.swift */, + 02F737DB250872C100C0E9AD /* AudioVisualizer.swift */, ); path = Helpers; sourceTree = ""; @@ -383,6 +392,7 @@ 46CFE6FB21714E6100E7FF73 /* Author.swift in Sources */, 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */, 02C7B579250184C400C4A310 /* BlurViewController.swift in Sources */, + 02F737DE2508736500C0E9AD /* AudioViewController.swift in Sources */, 02C7B5852501B54600C4A310 /* CIImage+JPEG.swift in Sources */, 02C7B57B250184E200C4A310 /* ColorViewController.swift in Sources */, 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */, @@ -397,12 +407,14 @@ 46CFE7012171559500E7FF73 /* FetchMediaOperation.swift in Sources */, 46D571F82173FC2700E7FF73 /* ImagePostDetailTableViewController.swift in Sources */, 02C7B5812501850F00C4A310 /* ColorInvertViewController.swift in Sources */, + 02F737E0250883AD00C0E9AD /* CommentTableViewCell.swift in Sources */, 46CFE7032171572600E7FF73 /* ImagePostCollectionViewCell.swift in Sources */, 4646379C2170091A00E7FF73 /* PostController.swift in Sources */, 464637992170048900E7FF73 /* FirebaseConvertible.swift in Sources */, 46A0366A21700F5100E7FF73 /* PostsCollectionViewController.swift in Sources */, 46D571F52173CF3E00E7FF73 /* UIImage+Ratio.swift in Sources */, 46463792216FFDD900E7FF73 /* Comment.swift in Sources */, + 02F737DC250872C100C0E9AD /* AudioVisualizer.swift in Sources */, 46CFE6F721707FA600E7FF73 /* UIViewController+InformationalAlert.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/LambdaTimeline/GoogleService-Info.plist b/LambdaTimeline/GoogleService-Info.plist index 801c6a4a..884b02df 100644 --- a/LambdaTimeline/GoogleService-Info.plist +++ b/LambdaTimeline/GoogleService-Info.plist @@ -4,6 +4,8 @@ CLIENT_ID 408849830962-1k5k0es64ef2la2sa67att0u07tosd87.apps.googleusercontent.com + NSMicrophoneUsageDescription + $(PRODUCT_NAME) needs access to microphone REVERSED_CLIENT_ID com.googleusercontent.apps.408849830962-1k5k0es64ef2la2sa67att0u07tosd87 API_KEY @@ -19,18 +21,18 @@ STORAGE_BUCKET morgan-s-timeline.appspot.com IS_ADS_ENABLED - + IS_ANALYTICS_ENABLED - + IS_APPINVITE_ENABLED - + IS_GCM_ENABLED - + IS_SIGNIN_ENABLED - + GOOGLE_APP_ID 1:408849830962:ios:c98be8ae0f90273ff3d82b DATABASE_URL https://morgan-s-timeline.firebaseio.com - \ No newline at end of file + diff --git a/LambdaTimeline/Helpers/AudioVisualizer.swift b/LambdaTimeline/Helpers/AudioVisualizer.swift new file mode 100644 index 00000000..5d35e0f7 --- /dev/null +++ b/LambdaTimeline/Helpers/AudioVisualizer.swift @@ -0,0 +1,277 @@ +// +// AudioVisualizer.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/8/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit + +@IBDesignable +public class AudioVisualizer: UIView { + + // MARK: IBInspectable Properties + + /// The width of a bar in points. + @IBInspectable public var barWidth: CGFloat = 10 { + didSet { + updateBars() + } + } + + /// The corner radius of a bar in points. If less than `0`, then it will default to half of the width of the bar. + @IBInspectable public var barCornerRadius: CGFloat = -1 { + didSet { + updateBars() + } + } + + /// The spacing between bars in points. + @IBInspectable public var barSpacing: CGFloat = 4 { + didSet { + updateBars() + } + } + + /// The color of a bar. + @IBInspectable public var barColor: UIColor = .systemGray { + didSet { + for bar in bars { + bar.backgroundColor = barColor + } + } + } + + /// The amount of time before a bar decays into the adjacent spot + @IBInspectable public var decaySpeed: Double = 0.01 { + didSet { + decayTimer?.invalidate() + decayTimer = nil + } + } + + /// The fraction the newest value will decay by if not updated by the time a decay starts + @IBInspectable public var decayAmount: Double = 0.8 + + // MARK: Internal Properties + + private var bars = [UIView]() + private var values = [Double]() + + private weak var decayTimer: Timer? + private var newestValue: Double = 0 + + // MARK: - Object Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + + initialSetup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + initialSetup() + } + + func initialSetup() { + // Pre-fill values for Interface Builder preview + #if TARGET_INTERFACE_BUILDER + values = [ + 0.19767167952644904, + 0.30975147553721694, + 0.2717680681330001, + 0.25914398105158504, + 0.3413322535900626, + 0.311223010327166, + 0.3302641160440099, + 0.303853272136915, + 0.2659123465612464, + 0.2860924489760262, + 0.26477145407733543, + 0.23180693200970012, + 0.24445487891619533, + 0.21484121767935302, + 0.19688917099112885, + 0.19020094289324854, + 0.17402194532721785, + 0.1600055988294578, + 0.15120753744055154, + 0.13789741397752767, + 0.13231033268544698, + 0.1270923459375989, + 0.1121238175344413, + 0.12400069790665748, + 0.24978783142512598, + 0.233063298365594, + 0.5375441947045457, + 0.47456518731446534, + 0.5236630241490436, + 0.4692151822551929, + 0.4255172022748686, + 0.46023063710569184, + 0.42934908823397355, + 0.37221041959882545, + 0.4685050055667653, + 0.4209394065681193, + 0.46643118034506187, + 0.4292307341708633, + 0.3814422662003417, + 0.4386719969186142, + 0.3956598546828729 + ] + #endif + + // Build the inner bars + self.updateBars() + } + + deinit { + // Invalidate the timer if it is still active + decayTimer?.invalidate() + } + + // MARK: - Layout + override public func layoutSubviews() { + updateBars() + } + + private func updateBars() { + // Clean up old bars + for bar in bars { + bar.removeFromSuperview() + } + + var newBars = [UIView]() + + + // Make sure the width of a bar and spacing is greater than 0, and that the available width is also greater than 0 + guard round(barWidth) > 0, barSpacing >= 0, bounds.width > 0, bounds.height > 0 else { + // Not enough information to create a single bar, so bail early + bars = [] + return + } + + // Calculate number of bars we will be able to display + var numberOfBarsToCreate = Int(bounds.width/(barWidth + barSpacing)) + + // Helper function for creating bars + func createBar(_ positionFromCenter: Int) { + let bar = UIView(frame: frame(forBar: positionFromCenter)) + bar.backgroundColor = barColor + bar.layer.cornerRadius = (barCornerRadius < 0 || barCornerRadius > barWidth/2) ? floor(barWidth/3) : barCornerRadius + + numberOfBarsToCreate -= 1 + newBars.append(bar) + self.addSubview(bar) + } + + // Create the center bar + createBar(0) + + // Keep creating bars in pairs until there is no more room + var position = 1 + while numberOfBarsToCreate > 0 { + // Create the symmetric pairs of bars starting from the center + createBar(-position) + createBar(position) + + position += 1 + } + + bars = newBars + } + + /// Calculate the frame of a particular bar + /// - Parameter positionFromCenter: The distance of the bar from the center (which is 0) + private func frame(forBar positionFromCenter: Int) -> CGRect { + let valueIndex = Int(positionFromCenter.magnitude) + + return frame(forBar: positionFromCenter, value: (valueIndex < values.count) ? values[valueIndex] : 0) + } + + /// Calculate the frame of a particular bar, specifying a value + /// - Parameter positionFromCenter: The distance of the bar from the center (which is 0) + private func frame(forBar positionFromCenter: Int, value: Double) -> CGRect { + let maxValue = (1 - CGFloat(positionFromCenter.magnitude)*(barWidth + barSpacing)/bounds.width/2)*bounds.height/2 + let height = CGFloat(value)*maxValue + + return CGRect(x: floor(bounds.width/2) + CGFloat(positionFromCenter)*(barWidth + barSpacing) - barWidth/2, y: floor(bounds.height/2) - height, width: barWidth, height: height*2) + } + + // MARK: - Animation + + /// Start the decay timer, but only if if hasn't been created yet + private func startTimer() { + guard decayTimer == nil else { return } + + decayTimer = Timer.scheduledTimer(withTimeInterval: decaySpeed, repeats: true) { [weak self] (_) in + guard let self = self else { return } + + self.decayNewestValue() + } + } + + private func decayNewestValue() { + values.insert(newestValue, at: 0) + + // Trim the end of the values array if there are too many for the number of bars + let currentCount = values.count + let maxCount = (bars.count + 1)/2 + /* + Note that the amount of bars will always be either 0, or an odd number (since the bars are counted in pairs after the first central bar), so we chose a "transformation" (a mathematical function) that satisfies this: value index = floor((bar index + 1)/2) + + Bar index: 0 1 2 3 4 5 6 7 8 9 ... + (valid bar index): 0 1 - 3 - 5 - 7 - 9 ... + Value index: 0 1 1 2 2 3 3 4 4 5 ... + + */ + if currentCount > maxCount { + values.removeSubrange(maxCount ..< currentCount) + } + + // Update the frames of each bar + for (positionFromCenter, value) in values.enumerated() { + if positionFromCenter == 0 { + bars[0].frame = frame(forBar: positionFromCenter, value: value) + } else { + bars[positionFromCenter*2 - 1].frame = frame(forBar: -positionFromCenter, value: value) + bars[positionFromCenter*2].frame = frame(forBar: positionFromCenter, value: value) + } + } + + // Decay the newest value + newestValue = newestValue*decayAmount + + // Check if the values are empty + let totalValue = values.reduce(0.0) { $0 + $1 } + if totalValue <= 0.000001 { + // Note that total value may never reach 0, but this is small enough to clear everything out + decayTimer?.invalidate() + decayTimer = nil + } + } + + // MARK: - Public Methods + + /// Add a value to the visualizer. Be sure to call `AVAudioPlayer.isMeteringEnabled = true`, and `AVAudioPlayer.updateMeters()` before every call to `AVAudioPlayer.averagePower(forChannel: 0)` + /// - Parameter decibelValue: The value you would get out of `AVAudioPlayer.averagePower(forChannel: 0)` + public func addValue(decibelValue: Float) { + addValue(decibelValue: Double(decibelValue)) + } + + /// Add a value to the visualizer. Be sure to call `AVAudioPlayer.isMeteringEnabled = true`, and `AVAudioPlayer.updateMeters()` before every call to `AVAudioPlayer.averagePower(forChannel: 0)` + /// - Parameter decibelValue: The value you would get out of `AVAudioPlayer.averagePower(forChannel: 0)` + public func addValue(decibelValue: Double) { + let normalizedValue = __exp10(decibelValue/20) + + newestValue = normalizedValue + + startTimer() + } + +} + + diff --git a/LambdaTimeline/Model Controllers/PostController.swift b/LambdaTimeline/Model Controllers/PostController.swift index 29ce3869..d2d28dd6 100644 --- a/LambdaTimeline/Model Controllers/PostController.swift +++ b/LambdaTimeline/Model Controllers/PostController.swift @@ -29,23 +29,34 @@ class PostController { NSLog("Error posting image post: \(error)") completion(false) } - + completion(true) } } } - func addComment(with text: String, to post: inout Post) { + func addTextComment(with text: String, to post: inout Post) { guard let currentUser = Auth.auth().currentUser, let author = Author(user: currentUser) else { return } - let comment = Comment(text: text, author: author) + let comment = Comment(text: text, audio: nil, author: author) post.comments.append(comment) savePostToFirebase(post) } + func addAudioComment(with url: URL, to post: inout Post) { + + guard let currentUser = Auth.auth().currentUser, + let author = Author(user: currentUser) else { return } + let comment = Comment(text: "Audio Comment", audio: url, author: author) + post.comments.append(comment) + savePostToFirebase(post) + } + + + func observePosts(completion: @escaping (Error?) -> Void) { postsRef.observe(.value, with: { (snapshot) in diff --git a/LambdaTimeline/Models/Comment.swift b/LambdaTimeline/Models/Comment.swift index a71ffe4d..c86934d9 100644 --- a/LambdaTimeline/Models/Comment.swift +++ b/LambdaTimeline/Models/Comment.swift @@ -12,32 +12,36 @@ import FirebaseAuth class Comment: FirebaseConvertible, Equatable { static private let textKey = "text" + static private let audioKey = "audio" static private let author = "author" static private let timestampKey = "timestamp" - let text: String + let text: String? + let audio: URL? let author: Author let timestamp: Date - init(text: String, author: Author, timestamp: Date = Date()) { + init(text: String, audio: URL? = nil, author: Author, timestamp: Date = Date()) { self.text = text + self.audio = audio self.author = author self.timestamp = timestamp } init?(dictionary: [String : Any]) { - guard let text = dictionary[Comment.textKey] as? String, + guard let text = dictionary[Comment.textKey] as? String, let audioString = dictionary[Comment.audioKey] as? String, let audio = URL(string: audioString), let authorDictionary = dictionary[Comment.author] as? [String: Any], let author = Author(dictionary: authorDictionary), let timestampTimeInterval = dictionary[Comment.timestampKey] as? TimeInterval else { return nil } - self.text = text + self.audio = audio self.author = author self.timestamp = Date(timeIntervalSince1970: timestampTimeInterval) } var dictionaryRepresentation: [String: Any] { return [Comment.textKey: text, + Comment.audioKey: audio?.absoluteString ?? "No Audio", Comment.author: author.dictionaryRepresentation, Comment.timestampKey: timestamp.timeIntervalSince1970] } diff --git a/LambdaTimeline/Models/Post.swift b/LambdaTimeline/Models/Post.swift index ec961e9f..fc9f9fa0 100644 --- a/LambdaTimeline/Models/Post.swift +++ b/LambdaTimeline/Models/Post.swift @@ -11,6 +11,7 @@ import FirebaseAuth enum MediaType: String { case image + case audio } class Post { @@ -68,7 +69,7 @@ class Post { var title: String? { return comments.first?.text } - + static private let mediaKey = "media" static private let ratioKey = "ratio" static private let mediaTypeKey = "mediaType" diff --git a/LambdaTimeline/Resources/Info.plist b/LambdaTimeline/Resources/Info.plist index 1f172fc7..fae134ee 100644 --- a/LambdaTimeline/Resources/Info.plist +++ b/LambdaTimeline/Resources/Info.plist @@ -4,6 +4,8 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + NSMicrophoneUsageDescription + $(PRODUCT_NAME) needs access to use microphone CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -33,6 +35,8 @@ NSPhotoLibraryUsageDescription In order to allow you to add photos to posts + NSPhotoLibraryAddUsageDescription + in order to edit photots that you post UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard index 115645fc..b1e5fa14 100644 --- a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard +++ b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard @@ -266,7 +266,7 @@ - + @@ -316,29 +316,69 @@ - - + + - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -358,11 +398,107 @@ + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -922,10 +1058,13 @@ - + + + + diff --git a/LambdaTimeline/View Controllers/AudioViewController.swift b/LambdaTimeline/View Controllers/AudioViewController.swift new file mode 100644 index 00000000..7cee25f9 --- /dev/null +++ b/LambdaTimeline/View Controllers/AudioViewController.swift @@ -0,0 +1,326 @@ +// +// AudioViewController.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/8/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class AudioViewController: UIViewController { + + var audioPlayer: AVAudioPlayer? { + didSet { + guard let audioPlayer = audioPlayer else { + return + } + audioPlayer.delegate = self + audioPlayer.isMeteringEnabled = true + updateViews() + } + } + + weak var timer: Timer? + + var recordingURL: URL? + var savedRecording: URL? + + var post: Post! + var postController: PostController! + + var audioRecorder: AVAudioRecorder? + + @IBOutlet var playButton: UIButton! + @IBOutlet var recordButton: UIButton! + @IBOutlet var timeElapsedLabel: UILabel! + @IBOutlet var timeRemainingLabel: UILabel! + @IBOutlet var timeSlider: UISlider! + @IBOutlet var audioVisualizer: AudioVisualizer! + + private lazy var timeIntervalFormatter: DateComponentsFormatter = { + + let formatting = DateComponentsFormatter() + formatting.unitsStyle = .positional // 00:00 mm:ss + formatting.zeroFormattingBehavior = .pad + formatting.allowedUnits = [.minute, .second] + return formatting + }() + + + // MARK: - View Controller Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + timeElapsedLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeElapsedLabel.font.pointSize, + weight: .regular) + timeRemainingLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeRemainingLabel.font.pointSize, + weight: .regular) + + loadAudio() + updateViews() + } + func updateViews() { + playButton.isEnabled = !isRecording + recordButton.isEnabled = !isPlaying + timeSlider.isEnabled = !isRecording + playButton.isSelected = isPlaying + recordButton.isSelected = isRecording + if !isRecording { + let elapsedTime = audioPlayer?.currentTime ?? 0 + let duration = audioPlayer?.duration ?? 0 + let timeRemaining = duration.rounded() - elapsedTime + timeElapsedLabel.text = timeIntervalFormatter.string(from: elapsedTime) + timeSlider.minimumValue = 0 + timeSlider.maximumValue = Float(duration) + timeSlider.value = Float(elapsedTime) + timeRemainingLabel.text = "-" + timeIntervalFormatter.string(from: timeRemaining)! + } else { + let elapsedTime = audioRecorder?.currentTime ?? 0 + timeElapsedLabel.text = "--:--" + timeSlider.minimumValue = 0 + timeSlider.maximumValue = 1 + timeSlider.value = 0 + timeRemainingLabel.text = timeIntervalFormatter.string(from: elapsedTime) + } + } + + + deinit { + timer?.invalidate() + } + + + // MARK: - Timer + + + func startTimer() { + timer?.invalidate() + + timer = Timer.scheduledTimer(withTimeInterval: 0.030, repeats: true) { [weak self] (_) in + + guard let self = self else { return } + + self.updateViews() + + if let audioRecorder = self.audioRecorder, + self.isRecording == true { + + audioRecorder.updateMeters() + self.audioVisualizer.addValue(decibelValue: audioRecorder.averagePower(forChannel: 0)) + + } + + if let audioPlayer = self.audioPlayer, + self.isPlaying == true { + + audioPlayer.updateMeters() + self.audioVisualizer.addValue(decibelValue: audioPlayer.averagePower(forChannel: 0)) + } + } + } + + func cancelTimer() { + timer?.invalidate() + timer = nil + } + + + + // MARK: - Playback + + var isPlaying: Bool { + audioPlayer?.isPlaying ?? false + } + + func loadAudio() { + guard let songURL = recordingURL else { return } + + do { + audioPlayer = try AVAudioPlayer(contentsOf: songURL) + } catch { + preconditionFailure("Failure to load audio file: \(error)") + } + } + + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) // can fail if on a phone call, for instance + } + + + func play() { + do { + try prepareAudioSession() + audioPlayer?.play() + updateViews() + startTimer() + } catch { + print("Cannot play audio: \(error)") + } + + + } + + func pause() { + audioPlayer?.pause() + updateViews() + cancelTimer() + } + + + // MARK: - Recording + + var isRecording: Bool { + audioRecorder?.isRecording ?? false + } + + func createNewRecordingURL() -> URL { + let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + let name = ISO8601DateFormatter.string(from: Date(), timeZone: .current, formatOptions: .withInternetDateTime) + let file = documents.appendingPathComponent(name, isDirectory: false).appendingPathExtension("caf") + + print("recording URL: \(file)") + return file + } + + + func requestPermissionOrStartRecording() { + switch AVAudioSession.sharedInstance().recordPermission { + case .undetermined: + AVAudioSession.sharedInstance().requestRecordPermission { granted in + guard granted == true else { + print("We need microphone access") + return + } + + print("Recording permission has been granted!") + // NOTE: Invite the user to tap record again, since we just interrupted them, and they may not have been ready to record + } + case .denied: + print("Microphone access has been blocked.") + + let alertController = UIAlertController(title: "Microphone Access Denied", message: "Please allow this app to access your Microphone.", preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: "Open Settings", style: .default) { (_) in + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + }) + + alertController.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alertController, animated: true, completion: nil) + case .granted: + startRecording() + @unknown default: + break + } + } + + + func startRecording() { + do { + try prepareAudioSession() + } catch { + print("Cannot record audio: \(error)") + return + } + + recordingURL = createNewRecordingURL() + + //can return option because you can give it invalid format and combinations + // let format = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)! + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44_100, channels: 1, interleaved: false)! + + do { + audioRecorder = try AVAudioRecorder(url: recordingURL!, format: format) + audioRecorder?.delegate = self + audioRecorder?.isMeteringEnabled = true + audioRecorder?.record() + updateViews() + startTimer() + } catch { + preconditionFailure("The audio recorder could not be created with \(recordingURL!) and \(format)") + } + + } + + func stopRecording() { + audioRecorder?.stop() + updateViews() + cancelTimer() + } + + // MARK: - Actions + + @IBAction func togglePlayback(_ sender: Any) { + if isPlaying { + pause() + } else { + play() + } + + } + + @IBAction func updateCurrentTime(_ sender: UISlider) { + if isPlaying { + pause() + } + audioPlayer?.currentTime = TimeInterval(sender.value) + updateViews() + } + + @IBAction func toggleRecording(_ sender: Any) { + if isRecording { + stopRecording() + } else { + requestPermissionOrStartRecording() + } + } + + @IBAction func saveRecording(_ sender: Any) { + if let savedRecording = savedRecording { + postController.addAudioComment(with: savedRecording, to: &self.post) + } else { + print("adioCommentNotSaved)") + } + DispatchQueue.main.async { + self.navigationController?.popViewController(animated: true) + } + } + } + + extension AudioViewController: AVAudioPlayerDelegate { + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + updateViews() + cancelTimer() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error = error { + print("Audio Player Error: \(error)") + } + } + } + + extension AudioViewController: AVAudioRecorderDelegate { + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + if let recordingURL = recordingURL { + audioPlayer = try? AVAudioPlayer(contentsOf: recordingURL) + savedRecording = recordingURL + } + recordingURL = nil + + } + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + if let error = error { + print("Audio Recorder Error: \(error)") + } + } + +} diff --git a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift index 31b43fa3..9f7f2595 100644 --- a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift @@ -13,7 +13,10 @@ class ImagePostDetailTableViewController: UITableViewController { override func viewDidLoad() { super.viewDidLoad() updateViews() + self.tableView.dataSource = self + self.tableView.delegate = self } + func updateViews() { @@ -24,7 +27,7 @@ class ImagePostDetailTableViewController: UITableViewController { imageView.image = image - titleLabel.text = post.title + titleLabel.text = post?.title authorLabel.text = post.author.displayName } @@ -32,32 +35,57 @@ class ImagePostDetailTableViewController: UITableViewController { @IBAction func createComment(_ sender: Any) { - let alert = UIAlertController(title: "Add a comment", message: "Write your comment below:", preferredStyle: .alert) + let alert = UIAlertController(title: "Add a comment", message: "Choose text or audio", preferredStyle: .alert) - var commentTextField: UITextField? + + + let addCommentAction = UIAlertAction(title: "Add Text", style: .default) { (_) in + self.createTextComment() + } + + let addAudioAction = UIAlertAction(title: "Add Audio", style: .default) { (_) in + DispatchQueue.main.async { + self.performSegue(withIdentifier: "addAudioComment", sender: self) + } + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + + alert.addAction(addCommentAction) + alert.addAction(addAudioAction) + alert.addAction(cancelAction) + present(alert, animated: true, completion: nil) + } + + func createTextComment() { + let alert = UIAlertController(title: "Add a comment", message: "Write your comment below:", preferredStyle: .alert) + + var commentTextField: UITextField? + alert.addTextField { (textField) in textField.placeholder = "Comment:" commentTextField = textField } - + let addCommentAction = UIAlertAction(title: "Add Comment", style: .default) { (_) in - + guard let commentText = commentTextField?.text else { return } - - self.postController.addComment(with: commentText, to: &self.post!) - + + self.postController.addTextComment(with: commentText, to: &self.post!) + DispatchQueue.main.async { self.tableView.reloadData() } } - + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - + alert.addAction(addCommentAction) alert.addAction(cancelAction) - + present(alert, animated: true, completion: nil) + } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -65,13 +93,11 @@ class ImagePostDetailTableViewController: UITableViewController { } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "CommentCell", for: indexPath) - - let comment = post?.comments[indexPath.row + 1] - - cell.textLabel?.text = comment?.text - cell.detailTextLabel?.text = comment?.author.displayName - + + guard let cell = tableView.dequeueReusableCell(withIdentifier: "CommentCell", for: indexPath) as? CommentTableViewCell else { return UITableViewCell() } + + guard let comment = post?.comments[indexPath.row + 1] else { return UITableViewCell() } + cell.comment = comment return cell } @@ -85,4 +111,14 @@ class ImagePostDetailTableViewController: UITableViewController { @IBOutlet weak var titleLabel: UILabel! @IBOutlet weak var authorLabel: UILabel! @IBOutlet weak var imageViewAspectRatioConstraint: NSLayoutConstraint! + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "addAudioComment" { + + let destinationVC = segue.destination as? AudioViewController + + destinationVC?.postController = postController + destinationVC?.post = post + } + } } diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index 1a43f1f3..0e10403e 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -166,11 +166,7 @@ class ImagePostViewController: ShiftableViewController { (sharpenController as? SharpenViewController)?.originalImagePost = self.originalImagePost let colorInvertController = tabController.children.first(where: { $0 is ColorInvertViewController }) (colorInvertController as? ColorInvertViewController)?.originalImagePost = self.originalImagePost - - - - - + } } } diff --git a/LambdaTimeline/Views/CommentTableViewCell.swift b/LambdaTimeline/Views/CommentTableViewCell.swift new file mode 100644 index 00000000..86ddc09f --- /dev/null +++ b/LambdaTimeline/Views/CommentTableViewCell.swift @@ -0,0 +1,121 @@ +// +// CommentTableViewCell.swift +// LambdaTimeline +// +// Created by Morgan Smith on 9/8/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class CommentTableViewCell: UITableViewCell { + + @IBOutlet weak var playButton: UIButton! + @IBOutlet weak var commentText: UILabel! + + @IBOutlet weak var authorLabel: UILabel! + + var comment: Comment? { + didSet { + updateViews() + } + } + + var audioPlayer: AVAudioPlayer? { + didSet { + guard let audioPlayer = audioPlayer else { + return + } + audioPlayer.delegate = self + togglePlaying() + } + } + + func updateViews() { + guard let comment = comment else { return } + if comment.text != "Audio Comment" { + playButton.adjustsImageWhenDisabled = true + playButton.backgroundImage(for: .disabled) + commentText.text = comment.text + authorLabel.text = comment.author.displayName + + } else { + playButton.backgroundImage(for: .normal) + loadAudio() + togglePlaying() + commentText.text = "Audio Comment" + authorLabel.text = comment.author.displayName + } + } + + func togglePlaying() { + playButton.isSelected = isPlaying + } + + var isPlaying: Bool { + audioPlayer?.isPlaying ?? false + } + + func loadAudio() { + guard let comment = comment else { return } + if comment.audio?.absoluteString == "No Audio", comment.audio == nil { + return + } else { + guard let songURL = comment.audio else { return } + do { + audioPlayer = try AVAudioPlayer(contentsOf: songURL) + } catch { + preconditionFailure("Failure to load audio file: \(error)") + } + } + } + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) // can fail if on a phone call, for instance + } + + func play() { + do { + try prepareAudioSession() + audioPlayer?.play() + togglePlaying() + } catch { + print("Cannot play audio: \(error)") + } + + + } + + func pause() { + audioPlayer?.pause() + togglePlaying() + } + + @IBAction func togglePlayback(_ sender: UIButton) { + if isPlaying { + pause() + } else { + play() + } + } + + + +} + +extension CommentTableViewCell: AVAudioPlayerDelegate { + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + togglePlaying() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error = error { + print("Audio Player Error: \(error)") + } + } +} + diff --git a/LambdaTimeline/Views/ImagePostCollectionViewCell.swift b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift index 3841cf65..88cdd3b7 100644 --- a/LambdaTimeline/Views/ImagePostCollectionViewCell.swift +++ b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift @@ -25,7 +25,7 @@ class ImagePostCollectionViewCell: UICollectionViewCell { func updateViews() { guard let post = post else { return } - titleLabel.text = post.title + titleLabel.text = post.comments.first?.text authorLabel.text = post.author.displayName }