diff --git a/LambdaTimeline.xcodeproj/project.pbxproj b/LambdaTimeline.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9436ebc4 --- /dev/null +++ b/LambdaTimeline.xcodeproj/project.pbxproj @@ -0,0 +1,448 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */; }; + 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46463782216FDE4C00E7FF73 /* Assets.xcassets */; }; + 46463786216FDE4C00E7FF73 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46463784216FDE4C00E7FF73 /* LaunchScreen.storyboard */; }; + 46463790216FFD1B00E7FF73 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646378F216FFD1B00E7FF73 /* Post.swift */; }; + 46463792216FFDD900E7FF73 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46463791216FFDD900E7FF73 /* Comment.swift */; }; + 4646379C2170091A00E7FF73 /* PostController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646379B2170091A00E7FF73 /* PostController.swift */; }; + 46A0366A21700F5100E7FF73 /* PostsCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A0366921700F5100E7FF73 /* PostsCollectionViewController.swift */; }; + 46A0366D2170158900E7FF73 /* SignInViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A0366C2170158900E7FF73 /* SignInViewController.swift */; }; + 46CFE6F521707D0000E7FF73 /* ImagePostViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6F421707D0000E7FF73 /* ImagePostViewController.swift */; }; + 46CFE6F721707FA600E7FF73 /* UIViewController+InformationalAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6F621707FA600E7FF73 /* UIViewController+InformationalAlert.swift */; }; + 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6F82170862C00E7FF73 /* ShiftableViewController.swift */; }; + 46CFE7032171572600E7FF73 /* ImagePostCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */; }; + 46D1A48924FF0BC4008D1CA7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 46D1A48824FF0BC4008D1CA7 /* Main.storyboard */; }; + 46D571F52173CF3E00E7FF73 /* UIImage+Ratio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D571F42173CF3E00E7FF73 /* UIImage+Ratio.swift */; }; + 46D571F82173FC2700E7FF73 /* ImagePostDetailTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D571F72173FC2700E7FF73 /* ImagePostDetailTableViewController.swift */; }; + 61736DE3255A83D1005112BE /* AudioRecorderController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61736DE2255A83D1005112BE /* AudioRecorderController.swift */; }; + 61736DE6255A8485005112BE /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61736DE5255A8485005112BE /* AudioVisualizer.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 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 = ""; }; + 46463782216FDE4C00E7FF73 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 46463785216FDE4C00E7FF73 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 46463787216FDE4C00E7FF73 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4646378F216FFD1B00E7FF73 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; + 46463791216FFDD900E7FF73 /* Comment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; + 4646379B2170091A00E7FF73 /* PostController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostController.swift; sourceTree = ""; }; + 46A0366921700F5100E7FF73 /* PostsCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostsCollectionViewController.swift; sourceTree = ""; }; + 46A0366C2170158900E7FF73 /* SignInViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInViewController.swift; sourceTree = ""; }; + 46CFE6F421707D0000E7FF73 /* ImagePostViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePostViewController.swift; sourceTree = ""; }; + 46CFE6F621707FA600E7FF73 /* UIViewController+InformationalAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+InformationalAlert.swift"; sourceTree = ""; }; + 46CFE6F82170862C00E7FF73 /* ShiftableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShiftableViewController.swift; sourceTree = ""; }; + 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePostCollectionViewCell.swift; sourceTree = ""; }; + 46D1A48824FF0BC4008D1CA7 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; + 46D571F42173CF3E00E7FF73 /* UIImage+Ratio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Ratio.swift"; sourceTree = ""; }; + 46D571F72173FC2700E7FF73 /* ImagePostDetailTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePostDetailTableViewController.swift; sourceTree = ""; }; + 61736DE2255A83D1005112BE /* AudioRecorderController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecorderController.swift; sourceTree = ""; }; + 61736DE5255A8485005112BE /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 46463775216FDE4B00E7FF73 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4646376F216FDE4B00E7FF73 = { + isa = PBXGroup; + children = ( + 4646377A216FDE4B00E7FF73 /* LambdaTimeline */, + 46463779216FDE4B00E7FF73 /* Products */, + ); + sourceTree = ""; + }; + 46463779216FDE4B00E7FF73 /* Products */ = { + isa = PBXGroup; + children = ( + 46463778216FDE4B00E7FF73 /* LambdaTimeline.app */, + ); + name = Products; + sourceTree = ""; + }; + 4646377A216FDE4B00E7FF73 /* LambdaTimeline */ = { + isa = PBXGroup; + children = ( + 46CFE7042171572900E7FF73 /* Views */, + 46A0366B21700F6100E7FF73 /* View Controllers */, + 4646379A2170090900E7FF73 /* Model Controllers */, + 46463795216FFF5800E7FF73 /* Models */, + 46463797216FFF7400E7FF73 /* Storyboards */, + 46CFE7062171573F00E7FF73 /* Helpers */, + 46463796216FFF6900E7FF73 /* Resources */, + ); + path = LambdaTimeline; + sourceTree = ""; + }; + 46463795216FFF5800E7FF73 /* Models */ = { + isa = PBXGroup; + children = ( + 4646378F216FFD1B00E7FF73 /* Post.swift */, + 46463791216FFDD900E7FF73 /* Comment.swift */, + ); + path = Models; + sourceTree = ""; + }; + 46463796216FFF6900E7FF73 /* Resources */ = { + isa = PBXGroup; + children = ( + 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */, + 46463782216FDE4C00E7FF73 /* Assets.xcassets */, + 46463787216FDE4C00E7FF73 /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + 46463797216FFF7400E7FF73 /* Storyboards */ = { + isa = PBXGroup; + children = ( + 46D1A48824FF0BC4008D1CA7 /* Main.storyboard */, + 46463784216FDE4C00E7FF73 /* LaunchScreen.storyboard */, + ); + path = Storyboards; + sourceTree = ""; + }; + 4646379A2170090900E7FF73 /* Model Controllers */ = { + isa = PBXGroup; + children = ( + 4646379B2170091A00E7FF73 /* PostController.swift */, + ); + path = "Model Controllers"; + sourceTree = ""; + }; + 46A0366B21700F6100E7FF73 /* View Controllers */ = { + isa = PBXGroup; + children = ( + 46A0366C2170158900E7FF73 /* SignInViewController.swift */, + 46A0366921700F5100E7FF73 /* PostsCollectionViewController.swift */, + 46CFE6F421707D0000E7FF73 /* ImagePostViewController.swift */, + 46D571F72173FC2700E7FF73 /* ImagePostDetailTableViewController.swift */, + 61736DE2255A83D1005112BE /* AudioRecorderController.swift */, + ); + path = "View Controllers"; + sourceTree = ""; + }; + 46CFE7042171572900E7FF73 /* Views */ = { + isa = PBXGroup; + children = ( + 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */, + 61736DE5255A8485005112BE /* AudioVisualizer.swift */, + ); + path = Views; + sourceTree = ""; + }; + 46CFE7062171573F00E7FF73 /* Helpers */ = { + isa = PBXGroup; + children = ( + 46D571F62173D6D200E7FF73 /* Extensions */, + 46CFE6F82170862C00E7FF73 /* ShiftableViewController.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 46D571F62173D6D200E7FF73 /* Extensions */ = { + isa = PBXGroup; + children = ( + 46D571F42173CF3E00E7FF73 /* UIImage+Ratio.swift */, + 46CFE6F621707FA600E7FF73 /* UIViewController+InformationalAlert.swift */, + ); + path = Extensions; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 46463777216FDE4B00E7FF73 /* LambdaTimeline */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4646378A216FDE4C00E7FF73 /* Build configuration list for PBXNativeTarget "LambdaTimeline" */; + buildPhases = ( + 46463774216FDE4B00E7FF73 /* Sources */, + 46463775216FDE4B00E7FF73 /* Frameworks */, + 46463776216FDE4B00E7FF73 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = LambdaTimeline; + productName = LambdaTimeline; + productReference = 46463778216FDE4B00E7FF73 /* LambdaTimeline.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 46463770216FDE4B00E7FF73 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1000; + LastUpgradeCheck = 1000; + ORGANIZATIONNAME = "Lambda School"; + TargetAttributes = { + 46463777216FDE4B00E7FF73 = { + CreatedOnToolsVersion = 10.0; + LastSwiftMigration = 1130; + }; + }; + }; + buildConfigurationList = 46463773216FDE4B00E7FF73 /* Build configuration list for PBXProject "LambdaTimeline" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4646376F216FDE4B00E7FF73; + productRefGroup = 46463779216FDE4B00E7FF73 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 46463777216FDE4B00E7FF73 /* LambdaTimeline */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 46463776216FDE4B00E7FF73 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 46D1A48924FF0BC4008D1CA7 /* Main.storyboard in Resources */, + 46463786216FDE4C00E7FF73 /* LaunchScreen.storyboard in Resources */, + 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 46463774216FDE4B00E7FF73 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 61736DE6255A8485005112BE /* AudioVisualizer.swift in Sources */, + 46A0366D2170158900E7FF73 /* SignInViewController.swift in Sources */, + 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */, + 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */, + 46CFE6F521707D0000E7FF73 /* ImagePostViewController.swift in Sources */, + 46463790216FFD1B00E7FF73 /* Post.swift in Sources */, + 61736DE3255A83D1005112BE /* AudioRecorderController.swift in Sources */, + 46D571F82173FC2700E7FF73 /* ImagePostDetailTableViewController.swift in Sources */, + 46CFE7032171572600E7FF73 /* ImagePostCollectionViewCell.swift in Sources */, + 4646379C2170091A00E7FF73 /* PostController.swift in Sources */, + 46A0366A21700F5100E7FF73 /* PostsCollectionViewController.swift in Sources */, + 46D571F52173CF3E00E7FF73 /* UIImage+Ratio.swift in Sources */, + 46463792216FFDD900E7FF73 /* Comment.swift in Sources */, + 46CFE6F721707FA600E7FF73 /* UIViewController+InformationalAlert.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 46463784216FDE4C00E7FF73 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 46463785216FDE4C00E7FF73 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 46463788216FDE4C00E7FF73 /* 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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + 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 = 12.0; + 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; + }; + 46463789216FDE4C00E7FF73 /* 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; + CODE_SIGN_IDENTITY = "iPhone Developer"; + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 4646378B216FDE4C00E7FF73 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = "$(SRCROOT)/LambdaTimeline/Resources/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.LambdaSchool.LambdaTimeline; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4646378C216FDE4C00E7FF73 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = "$(SRCROOT)/LambdaTimeline/Resources/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.LambdaSchool.LambdaTimeline; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 46463773216FDE4B00E7FF73 /* Build configuration list for PBXProject "LambdaTimeline" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 46463788216FDE4C00E7FF73 /* Debug */, + 46463789216FDE4C00E7FF73 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4646378A216FDE4C00E7FF73 /* Build configuration list for PBXNativeTarget "LambdaTimeline" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4646378B216FDE4C00E7FF73 /* Debug */, + 4646378C216FDE4C00E7FF73 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 46463770216FDE4B00E7FF73 /* Project object */; +} diff --git a/LambdaTimeline.xcodeproj/xcshareddata/xcschemes/LambdaTimeline.xcscheme b/LambdaTimeline.xcodeproj/xcshareddata/xcschemes/LambdaTimeline.xcscheme new file mode 100644 index 00000000..d1c2f96e --- /dev/null +++ b/LambdaTimeline.xcodeproj/xcshareddata/xcschemes/LambdaTimeline.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift b/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift new file mode 100644 index 00000000..d3133b5e --- /dev/null +++ b/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift @@ -0,0 +1,41 @@ +// +// UIImage+Ratio.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/14/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +extension UIImage { + var ratio: CGFloat { + return size.height / size.width + } + + /// 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/Helpers/Extensions/UIViewController+InformationalAlert.swift b/LambdaTimeline/Helpers/Extensions/UIViewController+InformationalAlert.swift new file mode 100644 index 00000000..d4480303 --- /dev/null +++ b/LambdaTimeline/Helpers/Extensions/UIViewController+InformationalAlert.swift @@ -0,0 +1,21 @@ +// +// UIViewController+InformationalAlert.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/12/18. +// Copyright © 2018 Lambda School. 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/LambdaTimeline/Helpers/ShiftableViewController.swift b/LambdaTimeline/Helpers/ShiftableViewController.swift new file mode 100644 index 00000000..5923d54f --- /dev/null +++ b/LambdaTimeline/Helpers/ShiftableViewController.swift @@ -0,0 +1,126 @@ +// +// Created by Spencer Curtis. +// Copyright © 2017-2018 Spencer Curtis. All rights reserved. +// + +/* + All you need to do is set your subclass of ShiftableViewController as the delegate for all + UITextFields and UITextViews that you want to be shifted up so the keyboard doesn't obscure it. + */ + +import UIKit + +class ShiftableViewController: UIViewController, UITextFieldDelegate, UITextViewDelegate, UIGestureRecognizerDelegate { + + var currentYShiftForKeyboard: CGFloat = 0 + + var textFieldBeingEdited: UITextField? + var textViewBeingEdited: UITextView? + + var keyboardDismissTapGestureRecognizer: UITapGestureRecognizer! + + override func viewDidLoad() { + super.viewDidLoad() + + setupKeyboardDismissTapGestureRecognizer() + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(notification:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @objc func stopEditingTextInput() { + if let textField = self.textFieldBeingEdited { + + textField.resignFirstResponder() + + self.textFieldBeingEdited = nil + self.textViewBeingEdited = nil + } else if let textView = self.textViewBeingEdited { + + textView.resignFirstResponder() + + self.textFieldBeingEdited = nil + self.textViewBeingEdited = nil + } + + guard keyboardDismissTapGestureRecognizer.isEnabled else { return } + + keyboardDismissTapGestureRecognizer.isEnabled = false + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + textFieldBeingEdited = textField + } + + func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { + textViewBeingEdited = textView + return true + } + + @objc func keyboardWillShow(notification: Notification) { + + keyboardDismissTapGestureRecognizer.isEnabled = true + + var keyboardSize: CGRect = .zero + + if let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, + keyboardRect.height != 0 { + keyboardSize = keyboardRect + } else if let keyboardRect = notification.userInfo?["UIKeyboardBoundsUserInfoKey"] as? CGRect { + keyboardSize = keyboardRect + } + + if let textField = textFieldBeingEdited { + if self.view.frame.origin.y == 0 { + + let yShift = yShiftWhenKeyboardAppearsFor(textInput: textField, keyboardSize: keyboardSize, nextY: keyboardSize.height) + self.currentYShiftForKeyboard = yShift + self.view.frame.origin.y -= yShift + } + } else if let textView = textViewBeingEdited { + if self.view.frame.origin.y == 0 { + + let yShift = yShiftWhenKeyboardAppearsFor(textInput: textView, keyboardSize: keyboardSize, nextY: keyboardSize.height) + self.currentYShiftForKeyboard = yShift + self.view.frame.origin.y -= yShift + } + } + } + + @objc func yShiftWhenKeyboardAppearsFor(textInput: UIView, keyboardSize: CGRect, nextY: CGFloat) -> CGFloat { + + let textFieldOrigin = self.view.convert(textInput.frame, from: textInput.superview!).origin.y + let textFieldBottomY = textFieldOrigin + textInput.frame.size.height + + // This is the y point that the textField's bottom can be at before it gets covered by the keyboard + let maximumY = self.view.frame.height - (keyboardSize.height + view.safeAreaInsets.bottom) + + if textFieldBottomY > maximumY { + // This makes the view shift the right amount to have the text field being edited just above they keyboard if it would have been covered by the keyboard. + return textFieldBottomY - maximumY + } else { + // It would go off the screen if moved, and it won't be obscured by the keyboard. + return 0 + } + } + + @objc func keyboardWillHide(notification: Notification) { + + if self.view.frame.origin.y != 0 { + + self.view.frame.origin.y += currentYShiftForKeyboard + } + + stopEditingTextInput() + } + + @objc func setupKeyboardDismissTapGestureRecognizer() { + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(stopEditingTextInput)) + tapGestureRecognizer.numberOfTapsRequired = 1 + + view.addGestureRecognizer(tapGestureRecognizer) + + keyboardDismissTapGestureRecognizer = tapGestureRecognizer + } +} diff --git a/LambdaTimeline/Model Controllers/PostController.swift b/LambdaTimeline/Model Controllers/PostController.swift new file mode 100644 index 00000000..d7852913 --- /dev/null +++ b/LambdaTimeline/Model Controllers/PostController.swift @@ -0,0 +1,42 @@ +// +// PostController.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/11/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +class PostController { + + var posts: [Post] = [] + + var currentUser: String? { + UserDefaults.standard.string(forKey: "username") + } + + func createImagePost(with title: String, image: UIImage, ratio: CGFloat?) { + + guard let currentUser = currentUser else { return } + + let post = Post(title: title, mediaType: .image(image), ratio: ratio, author: currentUser) + + posts.append(post) + } + + func addComment(with text: String, to post: inout Post) { + + guard let currentUser = currentUser else { return } + + let comment = Comment(text: text, author: currentUser) + post.comments.append(comment) + } + + func addComment(with audio: URL, to post: inout Post) { + guard let currentUser = currentUser else { return } + + let comment = Comment(author: currentUser, audioURL: audio) + post.comments.append(comment) + } +} diff --git a/LambdaTimeline/Models/Comment.swift b/LambdaTimeline/Models/Comment.swift new file mode 100644 index 00000000..4d9dc72f --- /dev/null +++ b/LambdaTimeline/Models/Comment.swift @@ -0,0 +1,37 @@ +// +// Comment.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/11/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import Foundation + +class Comment: Hashable { + + static private let textKey = "text" + static private let authorKey = "author" + static private let timestampKey = "timestamp" + + let text: String? + let author: String + let timestamp: Date + let audioURL: URL? + + init(text: String? = nil, author: String, timestamp: Date = Date(), audioURL: URL? = nil) { + self.text = text + self.author = author + self.timestamp = timestamp + self.audioURL = audioURL + } + + func hash(into hasher: inout Hasher) { + hasher.combine(timestamp.hashValue ^ author.hashValue) + } + + static func ==(lhs: Comment, rhs: Comment) -> Bool { + return lhs.author == rhs.author && + lhs.timestamp == rhs.timestamp + } +} diff --git a/LambdaTimeline/Models/Post.swift b/LambdaTimeline/Models/Post.swift new file mode 100644 index 00000000..15e1d65c --- /dev/null +++ b/LambdaTimeline/Models/Post.swift @@ -0,0 +1,40 @@ +// +// Post.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/11/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +enum MediaType { + case image(UIImage) +} + +class Post: Equatable { + + let mediaType: MediaType + let author: String + let timestamp: Date + var comments: [Comment] + var ratio: CGFloat? + var id: String? + + var title: String? { + return comments.first?.text + } + + init(title: String, mediaType: MediaType, ratio: CGFloat?, author: String, timestamp: Date = Date()) { + self.mediaType = mediaType + self.ratio = ratio + self.author = author + self.comments = [Comment(text: title, author: author)] + self.timestamp = timestamp + self.id = UUID().uuidString + } + + static func ==(lhs: Post, rhs: Post) -> Bool { + return lhs.id == rhs.id + } +} diff --git a/LambdaTimeline/Resources/AppDelegate.swift b/LambdaTimeline/Resources/AppDelegate.swift new file mode 100644 index 00000000..a44cc8ca --- /dev/null +++ b/LambdaTimeline/Resources/AppDelegate.swift @@ -0,0 +1,20 @@ +// +// AppDelegate.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/11/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } +} + diff --git a/LambdaTimeline/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/LambdaTimeline/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d8db8d65 --- /dev/null +++ b/LambdaTimeline/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/LambdaTimeline/Resources/Assets.xcassets/Contents.json b/LambdaTimeline/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/LambdaTimeline/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/LambdaTimeline/Resources/Info.plist b/LambdaTimeline/Resources/Info.plist new file mode 100644 index 00000000..b519c861 --- /dev/null +++ b/LambdaTimeline/Resources/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + Copy-paste-your-REVERSED_CLIENT_ID-from-GoogleService-Info.plist + + + + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSPhotoLibraryUsageDescription + In order to allow you to add photos to posts + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + NSMicrophoneUsageDescription + $(PRODUCT_NAME) needs to access your microphone to allow you to record audio comments. + + diff --git a/LambdaTimeline/Storyboards/Base.lproj/LaunchScreen.storyboard b/LambdaTimeline/Storyboards/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..bfa36129 --- /dev/null +++ b/LambdaTimeline/Storyboards/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LambdaTimeline/Storyboards/Main.storyboard b/LambdaTimeline/Storyboards/Main.storyboard new file mode 100644 index 00000000..86cbad5d --- /dev/null +++ b/LambdaTimeline/Storyboards/Main.storyboard @@ -0,0 +1,627 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LambdaTimeline/View Controllers/AudioRecorderController.swift b/LambdaTimeline/View Controllers/AudioRecorderController.swift new file mode 100644 index 00000000..10b54a06 --- /dev/null +++ b/LambdaTimeline/View Controllers/AudioRecorderController.swift @@ -0,0 +1,311 @@ +// +// AudioRecorderController.swift +// LambdaTimeline +// +// Created by Kenneth Jones on 11/10/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class AudioRecorderController: UIViewController { + + var post: Post! + var postController: PostController! + + var audioPlayer: AVAudioPlayer? { + didSet { + guard let audioPlayer = audioPlayer else { return } + + audioPlayer.delegate = self + audioPlayer.isMeteringEnabled = true + updateViews() + } + } + + 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! + @IBOutlet weak var saveButton: UIBarButtonItem! + + private lazy var timeIntervalFormatter: DateComponentsFormatter = { + let formatting = DateComponentsFormatter() + formatting.unitsStyle = .positional + 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() + } + + 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 + + weak var timer: 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() { +// 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: []) + } + + func play() { + do { + try prepareAudioSession() + audioPlayer?.play() + updateViews() + startTimer() + } catch { + print("Cannot play audio: \(error)") + } + } + + func pause() { + audioPlayer?.pause() + updateViews() + } + + // 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!") + } + 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() + + 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() + } + } + + @IBAction func saveAudioComment(_ sender: Any) { + if let recordedAudio = recordingURL { + postController.addComment(with: recordedAudio, to: &post) + } + + navigationController?.popViewController(animated: true) + } +} + + +extension AudioRecorderController: AVAudioPlayerDelegate { + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + updateViews() + } + + 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 + cancelTimer() + } + + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + if let error = error { + print("Audio Recording Error: \(error)") + } + } +} diff --git a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift new file mode 100644 index 00000000..0ba3dc5e --- /dev/null +++ b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift @@ -0,0 +1,195 @@ +// +// ImagePostDetailTableViewController.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/14/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class ImagePostDetailTableViewController: UITableViewController { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var imageViewAspectRatioConstraint: NSLayoutConstraint! + @IBOutlet weak var commentButton: UIBarButtonItem! + + var post: Post! + var postController: PostController! + var imageData: Data? + + var audioPlayer: AVAudioPlayer? { + didSet { + guard let audioPlayer = audioPlayer else { return } + + audioPlayer.delegate = self + audioPlayer.isMeteringEnabled = true + updateViews() + } + } + + var isPlaying: Bool { + audioPlayer?.isPlaying ?? false + } + + override func viewDidLoad() { + super.viewDidLoad() + updateViews() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.reloadData() + } + + func updateViews() { + + guard case MediaType.image(let image) = post.mediaType else { return } + + title = post?.title + + imageView.image = image + + titleLabel.text = post.title + authorLabel.text = post.author + } + + // MARK: - Table view data source + + @IBAction func createComment(_ sender: Any) { + + let alert = UIAlertController(title: "New Comment", message: "Which kind of comment do you want to create?", preferredStyle: .actionSheet) + + let textCommentAction = UIAlertAction(title: "Text", style: .default) { (_) in + self.createTextComment() + } + + let audioCommentAction = UIAlertAction(title: "Audio", style: .default) { (_) in + self.createAudioComment() + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + + alert.addAction(textCommentAction) + alert.addAction(audioCommentAction) + alert.addAction(cancelAction) + + self.present(alert, animated: true, completion: nil) + } + + func createAudioComment() { + performSegue(withIdentifier: "AddAudioComment", sender: 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!) + + 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 { + return (post?.comments.count ?? 0) - 1 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "CommentCell", for: indexPath) + + let comment = post?.comments[indexPath.row + 1] + + if comment?.audioURL == nil { + cell.textLabel?.text = comment?.text + cell.detailTextLabel?.text = comment?.author + } else if comment?.text == nil { + cell.textLabel?.text = "\(comment!.author)'s Audio Comment" + cell.detailTextLabel?.text = "Tap to play" + } + + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let comment = post?.comments[indexPath.row + 1] + + if comment?.audioURL == nil { + + } else if comment?.text == nil { + audioPlayer = try? AVAudioPlayer(contentsOf: (comment?.audioURL)!) + togglePlayback() + } + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "AddAudioComment" { + if let audioVC = segue.destination as? AudioRecorderController { + audioVC.postController = postController + audioVC.post = post + } + } + } + + func togglePlayback() { + if isPlaying { + pause() + } else { + play() + } + } + + func play() { + do { + try prepareAudioSession() + audioPlayer?.play() + } catch { + print("Cannot play audio: \(error)") + } + } + + func pause() { + audioPlayer?.pause() + } + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) + } +} + +extension ImagePostDetailTableViewController: 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/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift new file mode 100644 index 00000000..f41237d8 --- /dev/null +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -0,0 +1,278 @@ +// +// ImagePostViewController.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/12/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit +import CoreImage +import CoreImage.CIFilterBuiltins +import Photos + +class ImagePostViewController: ShiftableViewController { + + @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! + + @IBOutlet weak var addFilterLabel: UILabel! + @IBOutlet weak var clearFiltersButton: UIButton! + @IBOutlet weak var toneButton: UIButton! + @IBOutlet weak var vintageButton: UIButton! + @IBOutlet weak var noirButton: UIButton! + @IBOutlet weak var tweakLabel: UILabel! + @IBOutlet weak var exposureLabel: UILabel! + @IBOutlet weak var exposureSlider: UISlider! + @IBOutlet weak var vibranceLabel: UILabel! + @IBOutlet weak var vibranceSlider: UISlider! + @IBOutlet weak var posterizeLabel: UILabel! + @IBOutlet weak var posterizeSlider: UISlider! + + private let context = CIContext() + private let toneFilter = CIFilter.sRGBToneCurveToLinear() + private let vintageFilter = CIFilter.photoEffectInstant() + private let noirFilter = CIFilter.photoEffectNoir() + private let exposureFilter = CIFilter.exposureAdjust() + private let vibranceFilter = CIFilter.vibrance() + private let posterizeFilter = CIFilter.colorPosterize() + + var postController: PostController! + var post: Post? + var imageData: Data? + var selectedFilter: CIFilter? + + var originalImage: UIImage? { + didSet { + guard let originalImage = originalImage else { + scaledImage = nil + return + } + + var scaledSize = imageView.bounds.size + let scale = imageView.contentScaleFactor + + scaledSize.width *= scale + scaledSize.height *= scale + + guard let scaledUIImage = originalImage.imageByScaling(toSize: scaledSize) else { + scaledImage = nil + return + } + + scaledImage = CIImage(image: scaledUIImage) + } + } + + var scaledImage: CIImage? { + didSet { + updateImage() + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + setImageViewHeight(with: 1.0) + + addFilterLabel.isHidden = true + clearFiltersButton.isHidden = true + toneButton.isHidden = true + vintageButton.isHidden = true + noirButton.isHidden = true + tweakLabel.isHidden = true + exposureLabel.isHidden = true + exposureSlider.isHidden = true + vibranceLabel.isHidden = true + vibranceSlider.isHidden = true + posterizeLabel.isHidden = true + posterizeSlider.isHidden = true + } + + private func presentImagePickerController() { + + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { + presentInformationalAlertController(title: "Error", message: "The photo library is unavailable") + return + } + + DispatchQueue.main.async { + let imagePicker = UIImagePickerController() + imagePicker.delegate = self + imagePicker.sourceType = .photoLibrary + + self.present(imagePicker, animated: true, completion: nil) + } + } + + private func image(byFiltering inputImage: CIImage, withFilter inputFilter: CIFilter?) -> UIImage? { + var output = inputImage + + if inputFilter == toneFilter { + toneFilter.inputImage = inputImage + output = toneFilter.outputImage! + } else if inputFilter == vintageFilter { + vintageFilter.inputImage = inputImage + output = vintageFilter.outputImage! + } else if inputFilter == noirFilter { + noirFilter.inputImage = inputImage + output = noirFilter.outputImage! + } + + exposureFilter.inputImage = output + exposureFilter.ev = exposureSlider.value + + vibranceFilter.inputImage = exposureFilter.outputImage + vibranceFilter.amount = vibranceSlider.value + + posterizeFilter.inputImage = vibranceFilter.outputImage + posterizeFilter.levels = posterizeSlider.value + + guard let outputImage = posterizeFilter.outputImage else { return nil } + + guard let renderedCGImage = context.createCGImage(outputImage, from: inputImage.extent) else { return nil } + + return UIImage(cgImage: renderedCGImage) + } + + private func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage, withFilter: selectedFilter) + } else { + imageView.image = nil + } + } + + @IBAction func createPost(_ sender: Any) { + + view.endEditing(true) + + guard let image = imageView.image, + let title = titleTextField.text, title != "" else { + presentInformationalAlertController(title: "Uh-oh", message: "Make sure that you add a photo and a caption before posting.") + return + } + + postController.createImagePost(with: title, image: image, ratio: image.ratio) + + navigationController?.popViewController(animated: true) + } + + @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.") + default: + break + } + presentImagePickerController() + } + + @IBAction func addFilter(_ sender: Any) { + + } + + func setImageViewHeight(with aspectRatio: CGFloat) { + + imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio + + view.layoutSubviews() + } + + @IBAction func clearFilters(_ sender: Any) { + selectedFilter = nil + exposureSlider.value = 0.5 + vibranceSlider.value = 0 + posterizeSlider.value = 50 + updateImage() + } + + @IBAction func addToneFilter(_ sender: Any) { + selectedFilter = toneFilter + updateImage() + } + + @IBAction func addVintageFilter(_ sender: Any) { + selectedFilter = vintageFilter + updateImage() + } + + @IBAction func addNoirFilter(_ sender: Any) { + selectedFilter = noirFilter + updateImage() + } + + @IBAction func expoureChanged(_ sender: Any) { + updateImage() + } + + @IBAction func vibranceChanged(_ sender: Any) { + updateImage() + } + + @IBAction func posterizeChanged(_ sender: Any) { + updateImage() + } +} + +extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + + chooseImageButton.setTitle("", for: []) + + picker.dismiss(animated: true, completion: nil) + + guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } + + imageView.image = image + + setImageViewHeight(with: image.ratio) + + addFilterLabel.isHidden = false + clearFiltersButton.isHidden = false + toneButton.isHidden = false + vintageButton.isHidden = false + noirButton.isHidden = false + tweakLabel.isHidden = false + exposureLabel.isHidden = false + exposureSlider.isHidden = false + vibranceLabel.isHidden = false + vibranceSlider.isHidden = false + posterizeLabel.isHidden = false + posterizeSlider.isHidden = false + + exposureSlider.value = 0.5 + vibranceSlider.value = 0 + posterizeSlider.value = 50 + + originalImage = imageView.image + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true, completion: nil) + } +} diff --git a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift new file mode 100644 index 00000000..b9ed6b54 --- /dev/null +++ b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift @@ -0,0 +1,100 @@ +// +// PostsCollectionViewController.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/11/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +class PostsCollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout { + + var postController: PostController! + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + collectionView.reloadData() + } + + @IBAction func addPost(_ sender: Any) { + + let alert = UIAlertController(title: "New Post", message: "Which kind of post do you want to create?", preferredStyle: .actionSheet) + + let imagePostAction = UIAlertAction(title: "Image", style: .default) { (_) in + self.performSegue(withIdentifier: "AddImagePost", sender: nil) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + + alert.addAction(imagePostAction) + alert.addAction(cancelAction) + + self.present(alert, animated: true, completion: nil) + } + + // MARK: UICollectionViewDataSource + + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return postController?.posts.count ?? 0 + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + + let post = postController.posts[indexPath.row] + + switch post.mediaType { + + case .image: + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImagePostCell", for: indexPath) as? ImagePostCollectionViewCell else { return UICollectionViewCell() } + + cell.post = post + + return cell + } + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + + var size = CGSize(width: view.frame.width, height: view.frame.width) + + let post = postController.posts[indexPath.row] + + guard let ratio = post.ratio else { return size } + + size.height = size.width * ratio + + return size + } + + + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let cell = collectionView.cellForItem(at: indexPath) + + if let cell = cell as? ImagePostCollectionViewCell, + cell.imageView.image != nil { + self.performSegue(withIdentifier: "ViewImagePost", sender: 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 == "AddImagePost" { + let destinationVC = segue.destination as? ImagePostViewController + destinationVC?.postController = postController + + } else if segue.identifier == "ViewImagePost" { + + let destinationVC = segue.destination as? ImagePostDetailTableViewController + + guard let indexPath = collectionView.indexPathsForSelectedItems?.first else { return } + + destinationVC?.postController = postController + destinationVC?.post = postController.posts[indexPath.row] + } + } +} diff --git a/LambdaTimeline/View Controllers/SignInViewController.swift b/LambdaTimeline/View Controllers/SignInViewController.swift new file mode 100644 index 00000000..c81bb748 --- /dev/null +++ b/LambdaTimeline/View Controllers/SignInViewController.swift @@ -0,0 +1,44 @@ +// +// SignInViewController.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/11/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +class SignInViewController: UIViewController { + + @IBOutlet weak var nameTextField: UITextField! + + let postController = PostController() + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + segueIfUsernameExists() + } + + @IBAction func getStarted(_ sender: Any) { + UserDefaults.standard.set(nameTextField.text, forKey: "username") + segueIfUsernameExists() + } + + func segueIfUsernameExists() { + if UserDefaults.standard.string(forKey: "username") != nil { + performSegue(withIdentifier: "ModalPostsVC", sender: nil) + } + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "ModalPostsVC" { + guard let postsVC = (segue.destination as? UINavigationController)?.topViewController as? PostsCollectionViewController else { return } + + postsVC.postController = postController + } + } +} diff --git a/LambdaTimeline/Views/AudioVisualizer.swift b/LambdaTimeline/Views/AudioVisualizer.swift new file mode 100644 index 00000000..1a8ce8dd --- /dev/null +++ b/LambdaTimeline/Views/AudioVisualizer.swift @@ -0,0 +1,276 @@ +// +// AudioVisualizer.swift +// LambdaTimeline +// +// Created by Dimitri Bouniol Lambda on 1/16/20. +// Copyright © 2020 Lambda, Inc. 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/Views/ImagePostCollectionViewCell.swift b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift new file mode 100644 index 00000000..f83f73ed --- /dev/null +++ b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift @@ -0,0 +1,55 @@ +// +// ImagePostCollectionViewCell.swift +// LambdaTimeline +// +// Created by Spencer Curtis on 10/12/18. +// Copyright © 2018 Lambda School. All rights reserved. +// + +import UIKit + +class ImagePostCollectionViewCell: UICollectionViewCell { + + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + @IBOutlet weak var labelBackgroundView: UIView! + + var post: Post? { + didSet { + updateViews() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + setupLabelBackgroundView() + } + + override func prepareForReuse() { + super.prepareForReuse() + + imageView.image = nil + titleLabel.text = "" + authorLabel.text = "" + } + + func updateViews() { + guard let post = post, + case MediaType.image(let image) = post.mediaType else { return } + + titleLabel.text = post.title + authorLabel.text = post.author + imageView.image = image + } + + func setupLabelBackgroundView() { + labelBackgroundView.layer.cornerRadius = 8 + labelBackgroundView.clipsToBounds = true + } + + func setImage(_ image: UIImage?) { + imageView.image = image + } +} + diff --git a/README.md b/README.md index 1c1a0901..c2e2f9f6 100644 --- a/README.md +++ b/README.md @@ -33,18 +33,17 @@ Integrate your custom recording UI into the Lambda Timeline project.
Comment Suggestions

- - In the `Comment` object, change the `text`'s type to be an optional string, and create a new `audioURL: URL?` variable as well. Modify the `dictionaryRepresentation` and the `init?(dictionary: ...)` to accomodate the `audioURL` and the now optional `text` string. + - In the `Comment` object, change the `text`'s type to be an optional string, and create a new `audioURL: URL?` variable as well. Modify the `init?(text: ...)` to accomodate the `audioURL` and the now optional `text` string.

-2. In the `PostController`, add the ability to create a comment with the audio data that the user records, and save it to Firebase Storage, add the comment to its post, then save the post to the Firebase Database. +2. In the `PostController`, add the ability to create a comment with the audio data that the user records and add the comment to its post.
Post Controller Suggestions

- Create a separate function to create a comment with the audio data. - - You can very easily change the `store` method to instead take in data and a `StorageReference` to accomodate for storing both Post media data and now the audio data as well.

@@ -61,17 +60,7 @@ Integrate your custom recording UI into the Lambda Timeline project. 4. Create a new table view cell that displays at least the author of the audio comment, and a button to play the comment. -5. In the `ImagePostDetailViewController`, make sure that the audio is being fetched for the audio comments. You are welcome to fetch the audio for each audio comment however you want. - -
Audio Fetching Suggestions -

- - - You can implement the audio fetching similar to the way images are fetched on the `PostsCollectionViewController` by using operations, an operation queue, and a new cache. Make a new subclass of `ConcurrentOperation` that fetches audio using the comment's `audioURL` and a `URLSessionDataTask`. - -

-
- -6. Implement the ability to play a comment's audio from the new audio comment cell from step 2. As you implement the `AVAudioRecorder`, remember to add a microphone usage description in the Info.plist. +5. Implement the ability to play a comment's audio from the new audio comment cell from step 2. As you implement the `AVAudioRecorder`, remember to add a microphone usage description in the Info.plist. ## Go Further