From 775138505dd2d010cddb953fe1ee2a294bd23a20 Mon Sep 17 00:00:00 2001 From: Libby Thomas Date: Wed, 9 Sep 2020 16:55:35 -0500 Subject: [PATCH 1/2] MVP Day 2 --- LambdaTimeline.xcodeproj/project.pbxproj | 153 +---- LambdaTimeline/Helpers/UIImage+Scaling.swift | 38 ++ .../Model Controllers/PostController.swift | 123 +--- LambdaTimeline/Models/AudioVisualizer.swift | 276 ++++++++ LambdaTimeline/Models/Comment.swift | 32 +- LambdaTimeline/Models/Post.swift | 81 +-- LambdaTimeline/Resources/AppDelegate.swift | 30 +- LambdaTimeline/Resources/Info.plist | 2 + LambdaTimeline/Storyboards/Main.storyboard | 595 ++++++++++++++++++ .../AudioCommentTableViewCell.swift | 39 ++ .../AudioCommentViewController.swift | 324 ++++++++++ .../ImagePostDetailTableViewController.swift | 115 ++-- .../ImagePostViewController.swift | 190 +++++- .../PostsCollectionViewController.swift | 104 +-- .../SignInViewController.swift | 74 +-- .../Views/ImagePostCollectionViewCell.swift | 36 +- README.md | 9 + 17 files changed, 1649 insertions(+), 572 deletions(-) create mode 100644 LambdaTimeline/Helpers/UIImage+Scaling.swift create mode 100644 LambdaTimeline/Models/AudioVisualizer.swift create mode 100644 LambdaTimeline/Storyboards/Main.storyboard create mode 100644 LambdaTimeline/View Controllers/AudioCommentTableViewCell.swift create mode 100644 LambdaTimeline/View Controllers/AudioCommentViewController.swift diff --git a/LambdaTimeline.xcodeproj/project.pbxproj b/LambdaTimeline.xcodeproj/project.pbxproj index 95ea1e3d..56a517ea 100644 --- a/LambdaTimeline.xcodeproj/project.pbxproj +++ b/LambdaTimeline.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 50; objects = { /* Begin PBXBuildFile section */ @@ -13,53 +13,44 @@ 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 */; }; - 464637992170048900E7FF73 /* FirebaseConvertible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 464637982170048900E7FF73 /* FirebaseConvertible.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 */; }; - 46CFE6F32170757F00E7FF73 /* User+DictionaryRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6F22170757F00E7FF73 /* User+DictionaryRepresentation.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 */; }; - 46CFE6FB21714E6100E7FF73 /* Author.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6FA21714E6100E7FF73 /* Author.swift */; }; - 46CFE6FD217154F500E7FF73 /* Networking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6FC217154F500E7FF73 /* Networking.swift */; }; - 46CFE6FF2171556D00E7FF73 /* ConcurrentOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE6FE2171556D00E7FF73 /* ConcurrentOperation.swift */; }; - 46CFE7012171559500E7FF73 /* FetchMediaOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE7002171559500E7FF73 /* FetchMediaOperation.swift */; }; 46CFE7032171572600E7FF73 /* ImagePostCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */; }; - 46D571F32172D43B00E7FF73 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46D571F22172D43B00E7FF73 /* Cache.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 */; }; - E453457955DF907FCBD117F1 /* Pods_LambdaTimeline.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AA39CACB677861CF84322FB5 /* Pods_LambdaTimeline.framework */; }; + 68E13A5925092D6C003D8DFA /* UIImage+Scaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E13A5825092D6C003D8DFA /* UIImage+Scaling.swift */; }; + 68E13A5D2509426F003D8DFA /* AudioCommentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E13A5C2509426F003D8DFA /* AudioCommentViewController.swift */; }; + 68E13A5F25094798003D8DFA /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E13A5E25094798003D8DFA /* AudioVisualizer.swift */; }; + 68E13A6125098041003D8DFA /* AudioCommentTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E13A6025098041003D8DFA /* AudioCommentTableViewCell.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 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 = ""; }; - 46463780216FDE4B00E7FF73 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; 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 = ""; }; - 464637982170048900E7FF73 /* FirebaseConvertible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConvertible.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 = ""; }; - 46CFE6F22170757F00E7FF73 /* User+DictionaryRepresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "User+DictionaryRepresentation.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 = ""; }; - 46CFE6FA21714E6100E7FF73 /* Author.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Author.swift; sourceTree = ""; }; - 46CFE6FC217154F500E7FF73 /* Networking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Networking.swift; sourceTree = ""; }; - 46CFE6FE2171556D00E7FF73 /* ConcurrentOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcurrentOperation.swift; sourceTree = ""; }; - 46CFE7002171559500E7FF73 /* FetchMediaOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchMediaOperation.swift; sourceTree = ""; }; 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePostCollectionViewCell.swift; sourceTree = ""; }; - 46D571F22172D43B00E7FF73 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.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 = ""; }; - 5D2CCD6C68779B0A70AC37FA /* Pods-LambdaTimeline.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LambdaTimeline.release.xcconfig"; path = "Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline.release.xcconfig"; sourceTree = ""; }; - AA39CACB677861CF84322FB5 /* Pods_LambdaTimeline.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LambdaTimeline.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 68E13A5825092D6C003D8DFA /* UIImage+Scaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Scaling.swift"; sourceTree = ""; }; + 68E13A5C2509426F003D8DFA /* AudioCommentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCommentViewController.swift; sourceTree = ""; }; + 68E13A5E25094798003D8DFA /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; + 68E13A6025098041003D8DFA /* AudioCommentTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCommentTableViewCell.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -67,29 +58,17 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E453457955DF907FCBD117F1 /* Pods_LambdaTimeline.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 2B7D7DE8669C7C706445FCCC /* Pods */ = { - isa = PBXGroup; - children = ( - 33A2B89DE9E21538434BD640 /* Pods-LambdaTimeline.debug.xcconfig */, - 5D2CCD6C68779B0A70AC37FA /* Pods-LambdaTimeline.release.xcconfig */, - ); - name = Pods; - sourceTree = ""; - }; 4646376F216FDE4B00E7FF73 = { isa = PBXGroup; children = ( 4646377A216FDE4B00E7FF73 /* LambdaTimeline */, 46463779216FDE4B00E7FF73 /* Products */, - 2B7D7DE8669C7C706445FCCC /* Pods */, - 7C2A25429FC0F968FC1D35E7 /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +100,7 @@ 4646378F216FFD1B00E7FF73 /* Post.swift */, 46CFE6FA21714E6100E7FF73 /* Author.swift */, 46463791216FFDD900E7FF73 /* Comment.swift */, + 68E13A5E25094798003D8DFA /* AudioVisualizer.swift */, ); path = Models; sourceTree = ""; @@ -138,7 +118,7 @@ 46463797216FFF7400E7FF73 /* Storyboards */ = { isa = PBXGroup; children = ( - 4646377F216FDE4B00E7FF73 /* Main.storyboard */, + 46D1A48824FF0BC4008D1CA7 /* Main.storyboard */, 46463784216FDE4C00E7FF73 /* LaunchScreen.storyboard */, ); path = Storyboards; @@ -159,6 +139,8 @@ 46A0366921700F5100E7FF73 /* PostsCollectionViewController.swift */, 46CFE6F421707D0000E7FF73 /* ImagePostViewController.swift */, 46D571F72173FC2700E7FF73 /* ImagePostDetailTableViewController.swift */, + 68E13A5C2509426F003D8DFA /* AudioCommentViewController.swift */, + 68E13A6025098041003D8DFA /* AudioCommentTableViewCell.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -171,24 +153,12 @@ path = Views; sourceTree = ""; }; - 46CFE7052171573200E7FF73 /* Operations */ = { - isa = PBXGroup; - children = ( - 46CFE7002171559500E7FF73 /* FetchMediaOperation.swift */, - 46CFE6FE2171556D00E7FF73 /* ConcurrentOperation.swift */, - ); - path = Operations; - sourceTree = ""; - }; 46CFE7062171573F00E7FF73 /* Helpers */ = { isa = PBXGroup; children = ( - 46CFE7052171573200E7FF73 /* Operations */, 46D571F62173D6D200E7FF73 /* Extensions */, - 46D571F22172D43B00E7FF73 /* Cache.swift */, - 464637982170048900E7FF73 /* FirebaseConvertible.swift */, - 46CFE6FC217154F500E7FF73 /* Networking.swift */, 46CFE6F82170862C00E7FF73 /* ShiftableViewController.swift */, + 68E13A5825092D6C003D8DFA /* UIImage+Scaling.swift */, ); path = Helpers; sourceTree = ""; @@ -196,21 +166,12 @@ 46D571F62173D6D200E7FF73 /* Extensions */ = { isa = PBXGroup; children = ( - 46CFE6F22170757F00E7FF73 /* User+DictionaryRepresentation.swift */, 46D571F42173CF3E00E7FF73 /* UIImage+Ratio.swift */, 46CFE6F621707FA600E7FF73 /* UIViewController+InformationalAlert.swift */, ); path = Extensions; sourceTree = ""; }; - 7C2A25429FC0F968FC1D35E7 /* Frameworks */ = { - isa = PBXGroup; - children = ( - AA39CACB677861CF84322FB5 /* Pods_LambdaTimeline.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -218,12 +179,9 @@ isa = PBXNativeTarget; buildConfigurationList = 4646378A216FDE4C00E7FF73 /* Build configuration list for PBXNativeTarget "LambdaTimeline" */; buildPhases = ( - 4E9D00EBF902E9530D554E98 /* [CP] Check Pods Manifest.lock */, 46463774216FDE4B00E7FF73 /* Sources */, 46463775216FDE4B00E7FF73 /* Frameworks */, 46463776216FDE4B00E7FF73 /* Resources */, - 931381193B5D2856F9B0CA8C /* [CP] Embed Pods Frameworks */, - 8108F7AF1127B3F2AAFC77E1 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -273,73 +231,14 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 46D1A48924FF0BC4008D1CA7 /* Main.storyboard in Resources */, 46463786216FDE4C00E7FF73 /* LaunchScreen.storyboard in Resources */, 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */, - 46463781216FDE4B00E7FF73 /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 4E9D00EBF902E9530D554E98 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-LambdaTimeline-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 8108F7AF1127B3F2AAFC77E1 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 931381193B5D2856F9B0CA8C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 46463774216FDE4B00E7FF73 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -348,18 +247,16 @@ 46A0366D2170158900E7FF73 /* SignInViewController.swift in Sources */, 46CFE6FB21714E6100E7FF73 /* Author.swift in Sources */, 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */, + 68E13A6125098041003D8DFA /* AudioCommentTableViewCell.swift in Sources */, 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */, - 46CFE6FD217154F500E7FF73 /* Networking.swift in Sources */, 46CFE6F521707D0000E7FF73 /* ImagePostViewController.swift in Sources */, - 46CFE6FF2171556D00E7FF73 /* ConcurrentOperation.swift in Sources */, + 68E13A5F25094798003D8DFA /* AudioVisualizer.swift in Sources */, + 68E13A5D2509426F003D8DFA /* AudioCommentViewController.swift in Sources */, + 68E13A5925092D6C003D8DFA /* UIImage+Scaling.swift in Sources */, 46463790216FFD1B00E7FF73 /* Post.swift in Sources */, - 46CFE6F32170757F00E7FF73 /* User+DictionaryRepresentation.swift in Sources */, - 46D571F32172D43B00E7FF73 /* Cache.swift in Sources */, - 46CFE7012171559500E7FF73 /* FetchMediaOperation.swift in Sources */, 46D571F82173FC2700E7FF73 /* ImagePostDetailTableViewController.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 */, @@ -507,17 +404,17 @@ }; 4646378B216FDE4C00E7FF73 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 33A2B89DE9E21538434BD640 /* Pods-LambdaTimeline.debug.xcconfig */; 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 = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.LambdaSchool.LambdaTimeline; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -526,17 +423,17 @@ }; 4646378C216FDE4C00E7FF73 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5D2CCD6C68779B0A70AC37FA /* Pods-LambdaTimeline.release.xcconfig */; 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 = ""; + PRODUCT_BUNDLE_IDENTIFIER = com.LambdaSchool.LambdaTimeline; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/LambdaTimeline/Helpers/UIImage+Scaling.swift b/LambdaTimeline/Helpers/UIImage+Scaling.swift new file mode 100644 index 00000000..97fefc26 --- /dev/null +++ b/LambdaTimeline/Helpers/UIImage+Scaling.swift @@ -0,0 +1,38 @@ +// +// UIImage+Scaling.swift +// LambdaTimeline +// +// Created by Elizabeth Thomas on 9/9/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/Model Controllers/PostController.swift b/LambdaTimeline/Model Controllers/PostController.swift index 29ce3869..b18a95a8 100644 --- a/LambdaTimeline/Model Controllers/PostController.swift +++ b/LambdaTimeline/Model Controllers/PostController.swift @@ -6,122 +6,39 @@ // Copyright © 2018 Lambda School. All rights reserved. // -import Foundation -import FirebaseAuth -import FirebaseDatabase -import FirebaseStorage +import UIKit class PostController { + + static let shared = PostController() + + var posts: [Post] = [] - func createPost(with title: String, ofType mediaType: MediaType, mediaData: Data, ratio: CGFloat? = nil, completion: @escaping (Bool) -> Void = { _ in }) { + var currentUser: String? { + UserDefaults.standard.string(forKey: "username") + } + + func createImagePost(with title: String, image: UIImage, ratio: CGFloat?, audioURL: URL?) { - guard let currentUser = Auth.auth().currentUser, - let author = Author(user: currentUser) else { return } + guard let currentUser = currentUser else { return } - store(mediaData: mediaData, mediaType: mediaType) { (mediaURL) in - - guard let mediaURL = mediaURL else { completion(false); return } - - let imagePost = Post(title: title, mediaURL: mediaURL, ratio: ratio, author: author) - - self.postsRef.childByAutoId().setValue(imagePost.dictionaryRepresentation) { (error, ref) in - if let error = error { - NSLog("Error posting image post: \(error)") - completion(false) - } + let post = Post(title: title, mediaType: .image(image), ratio: ratio, author: currentUser, audioURL: audioURL) - completion(true) - } - } + posts.append(post) } func addComment(with text: String, to post: inout Post) { + guard let currentUser = currentUser else { return } - 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, author: currentUser, audioURL: nil) post.comments.append(comment) - - savePostToFirebase(post) - } - func observePosts(completion: @escaping (Error?) -> Void) { - - postsRef.observe(.value, with: { (snapshot) in - - guard let postDictionaries = snapshot.value as? [String: [String: Any]] else { return } - - var posts: [Post] = [] - - for (key, value) in postDictionaries { - - guard let post = Post(dictionary: value, id: key) else { continue } - - posts.append(post) - } - - self.posts = posts.sorted(by: { $0.timestamp > $1.timestamp }) - - completion(nil) - - }) { (error) in - NSLog("Error fetching posts: \(error)") - } - } - - func savePostToFirebase(_ post: Post, completion: (Error?) -> Void = { _ in }) { - - guard let postID = post.id else { return } - - let ref = postsRef.child(postID) - - ref.setValue(post.dictionaryRepresentation) } - private func store(mediaData: Data, mediaType: MediaType, completion: @escaping (URL?) -> Void) { - - let mediaID = UUID().uuidString - - let mediaRef = storageRef.child(mediaType.rawValue).child(mediaID) - - let uploadTask = mediaRef.putData(mediaData, metadata: nil) { (metadata, error) in - if let error = error { - NSLog("Error storing media data: \(error)") - completion(nil) - return - } - - if metadata == nil { - NSLog("No metadata returned from upload task.") - completion(nil) - return - } - - mediaRef.downloadURL(completion: { (url, error) in - - if let error = error { - NSLog("Error getting download url of media: \(error)") - } - - guard let url = url else { - NSLog("Download url is nil. Unable to create a Media object") - - completion(nil) - return - } - completion(url) - }) - } - - uploadTask.resume() + func addAudioComment(with url: URL, to post: inout Post) { + guard let currentUser = currentUser else { return } + + let comment = Comment(author: currentUser, audioURL: url) + post.comments.append(comment) } - - var posts: [Post] = [] - let currentUser = Auth.auth().currentUser - let postsRef = Database.database().reference().child("posts") - - let storageRef = Storage.storage().reference() - - } diff --git a/LambdaTimeline/Models/AudioVisualizer.swift b/LambdaTimeline/Models/AudioVisualizer.swift new file mode 100644 index 00000000..c4d1a0de --- /dev/null +++ b/LambdaTimeline/Models/AudioVisualizer.swift @@ -0,0 +1,276 @@ +// +// AudioVisualizer.swift +// LambdaTimeline +// +// Created by Elizabeth Thomas on 9/9/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/Models/Comment.swift b/LambdaTimeline/Models/Comment.swift index a71ffe4d..e8770549 100644 --- a/LambdaTimeline/Models/Comment.swift +++ b/LambdaTimeline/Models/Comment.swift @@ -7,41 +7,29 @@ // import Foundation -import FirebaseAuth -class Comment: FirebaseConvertible, Equatable { +class Comment: Hashable { static private let textKey = "text" - static private let author = "author" + static private let authorKey = "author" static private let timestampKey = "timestamp" - let text: String - let author: Author + let text: String? + let author: String let timestamp: Date + let audioURL: URL? - init(text: String, author: Author, timestamp: Date = Date()) { + init(text: String? = nil, author: String, timestamp: Date = Date(), audioURL: URL?) { self.text = text self.author = author self.timestamp = timestamp + self.audioURL = audioURL } - init?(dictionary: [String : Any]) { - guard let text = dictionary[Comment.textKey] as? String, - 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.author = author - self.timestamp = Date(timeIntervalSince1970: timestampTimeInterval) - } - - var dictionaryRepresentation: [String: Any] { - return [Comment.textKey: text, - Comment.author: author.dictionaryRepresentation, - Comment.timestampKey: timestamp.timeIntervalSince1970] + 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 index ec961e9f..83118dbf 100644 --- a/LambdaTimeline/Models/Post.swift +++ b/LambdaTimeline/Models/Post.swift @@ -6,74 +6,39 @@ // Copyright © 2018 Lambda School. All rights reserved. // -import Foundation -import FirebaseAuth +import UIKit -enum MediaType: String { - case image +enum MediaType { + case image(UIImage) } -class Post { +class Post: Equatable { - init(title: String, mediaURL: URL, ratio: CGFloat? = nil, author: Author, timestamp: Date = Date()) { - self.mediaURL = mediaURL - self.ratio = ratio - self.mediaType = .image - self.author = author - self.comments = [Comment(text: title, author: author)] - self.timestamp = timestamp - } - - init?(dictionary: [String : Any], id: String) { - guard let mediaURLString = dictionary[Post.mediaKey] as? String, - let mediaURL = URL(string: mediaURLString), - let mediaTypeString = dictionary[Post.mediaTypeKey] as? String, - let mediaType = MediaType(rawValue: mediaTypeString), - let authorDictionary = dictionary[Post.authorKey] as? [String: Any], - let author = Author(dictionary: authorDictionary), - let timestampTimeInterval = dictionary[Post.timestampKey] as? TimeInterval, - let captionDictionaries = dictionary[Post.commentsKey] as? [[String: Any]] else { return nil } - - self.mediaURL = mediaURL - self.mediaType = mediaType - self.ratio = dictionary[Post.ratioKey] as? CGFloat - self.author = author - self.timestamp = Date(timeIntervalSince1970: timestampTimeInterval) - self.comments = captionDictionaries.compactMap({ Comment(dictionary: $0) }) - self.id = id - } - - var dictionaryRepresentation: [String : Any] { - var dict: [String: Any] = [Post.mediaKey: mediaURL.absoluteString, - Post.mediaTypeKey: mediaType.rawValue, - Post.commentsKey: comments.map({ $0.dictionaryRepresentation }), - Post.authorKey: author.dictionaryRepresentation, - Post.timestampKey: timestamp.timeIntervalSince1970] - - guard let ratio = self.ratio else { return dict } - - dict[Post.ratioKey] = ratio - - return dict - } - - var mediaURL: URL let mediaType: MediaType - let author: Author + let author: String let timestamp: Date var comments: [Comment] - var id: String? var ratio: CGFloat? + var id: String? var title: String? { - return comments.first?.text + comments.first?.text + } + + var audioURL: URL? { + comments.first?.audioURL } - static private let mediaKey = "media" - static private let ratioKey = "ratio" - static private let mediaTypeKey = "mediaType" - static private let authorKey = "author" - static private let commentsKey = "comments" - static private let timestampKey = "timestamp" - static private let idKey = "id" + init(title: String, mediaType: MediaType, ratio: CGFloat?, author: String, timestamp: Date = Date(), audioURL: URL?) { + self.mediaType = mediaType + self.ratio = ratio + self.author = author + self.comments = [Comment(text: title, author: author, audioURL: audioURL)] + 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 index d889d46c..da112fe4 100644 --- a/LambdaTimeline/Resources/AppDelegate.swift +++ b/LambdaTimeline/Resources/AppDelegate.swift @@ -7,40 +7,14 @@ // import UIKit -import Firebase -import GoogleSignIn @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate, GIDSignInDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - FirebaseApp.configure() - - let signIn = GIDSignIn.sharedInstance() - signIn?.clientID = FirebaseApp.app()?.options.clientID - - if Auth.auth().currentUser != nil { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let postsNavigationController = storyboard.instantiateViewController(withIdentifier: "PostsNavigationController") - window?.rootViewController = postsNavigationController - window?.makeKeyAndVisible() - } - return true } - - func sign(_ signIn: GIDSignIn!, didSignInFor user: GIDGoogleUser!, - withError error: Error!) { - if let error = error { - if (error as NSError).code == GIDSignInErrorCode.hasNoAuthInKeychain.rawValue { - print("Error: The user has not signed in before or they have since signed out.") - } else { - print("\(error.localizedDescription)") - } - return - } - } } - diff --git a/LambdaTimeline/Resources/Info.plist b/LambdaTimeline/Resources/Info.plist index dab10c0a..f8683011 100644 --- a/LambdaTimeline/Resources/Info.plist +++ b/LambdaTimeline/Resources/Info.plist @@ -33,6 +33,8 @@ NSPhotoLibraryUsageDescription In order to allow you to add photos to posts + NSMicrophoneUsageDescription + $(PRODUCT_NAME) would like access to the microphone to record audio. UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/LambdaTimeline/Storyboards/Main.storyboard b/LambdaTimeline/Storyboards/Main.storyboard new file mode 100644 index 00000000..6d2ec443 --- /dev/null +++ b/LambdaTimeline/Storyboards/Main.storyboard @@ -0,0 +1,595 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LambdaTimeline/View Controllers/AudioCommentTableViewCell.swift b/LambdaTimeline/View Controllers/AudioCommentTableViewCell.swift new file mode 100644 index 00000000..cda70414 --- /dev/null +++ b/LambdaTimeline/View Controllers/AudioCommentTableViewCell.swift @@ -0,0 +1,39 @@ +// +// AudioCommentTableViewCell.swift +// LambdaTimeline +// +// Created by Elizabeth Thomas on 9/9/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class AudioCommentTableViewCell: UITableViewCell { + + var recordingURL: URL? + var audioPlayer: AVAudioPlayer? + + @IBOutlet weak var usernameLabel: UILabel! + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) + } + + @IBAction func playButtonTapped(_ sender: Any) { + do { + if let recordingURL = recordingURL{ + print(recordingURL) + audioPlayer = try AVAudioPlayer(contentsOf: recordingURL) + guard let audioPlayer = self.audioPlayer else { return } + audioPlayer.play() + } + } catch { + preconditionFailure("Failure to load audio file at path \(recordingURL!): \(error)") + } + } + + +} diff --git a/LambdaTimeline/View Controllers/AudioCommentViewController.swift b/LambdaTimeline/View Controllers/AudioCommentViewController.swift new file mode 100644 index 00000000..8cc8939c --- /dev/null +++ b/LambdaTimeline/View Controllers/AudioCommentViewController.swift @@ -0,0 +1,324 @@ +// +// AudioCommentViewController.swift +// LambdaTimeline +// +// Created by Elizabeth Thomas on 9/9/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +protocol VoiceCommentAddedDelegate { + func reloadData() +} + +class AudioCommentViewController: 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? + var post: Post! + var delegate: VoiceCommentAddedDelegate? + let postController = PostController.shared + + @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) + } + + 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 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 file = documents.appendingPathComponent("Audio Comment" + "\(Date())", isDirectory: false).appendingPathExtension("caf") +// +// print("recording URL: \(file)") +// +// return file +// } + + func newRecordingURL() -> URL { + let fm = FileManager.default + let documentsDir = try! fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + + // let randomNumber = Int.random(in: 0...1_000_00) + + return documentsDir.appendingPathComponent("TestRecording").appendingPathExtension("caf") + } + + 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 = newRecordingURL() + + let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 48_000, channels: 2, 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 saveRecording(_ sender: Any) { + let commentURL = newRecordingURL() + self.postController.addAudioComment(with: commentURL, to: &self.post) + self.delegate?.reloadData() + + navigationController?.popToRootViewController(animated: true) + + } + + @IBAction func toggleRecording(_ sender: Any) { + if isRecording { + stopRecording() + } else { + requestPermissionOrStartRecording() + } + } +} + + + + +extension AudioCommentViewController: 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 AudioCommentViewController: 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 Recorder Error: \(error)") + } + } + +} diff --git a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift index 31b43fa3..e249b90f 100644 --- a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift @@ -9,55 +9,77 @@ import UIKit 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! + let postController = PostController.shared + var imageData: Data? + override func viewDidLoad() { super.viewDidLoad() updateViews() } func updateViews() { - - guard let imageData = imageData, - let image = UIImage(data: imageData) else { return } + guard case MediaType.image(let image) = post.mediaType else { return } title = post?.title imageView.image = image - titleLabel.text = post.title - authorLabel.text = post.author.displayName + authorLabel.text = post.author } // MARK: - Table view data source @IBAction func createComment(_ sender: Any) { + + let alert = UIAlertController(title: "New Comment", message: "What kind of comment do you want to create?", preferredStyle: .actionSheet) - 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 textCommentAction = UIAlertAction(title: "Text", style: .default, handler: { (_) in + 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) + self.present(alert, animated: true, completion: nil) + }) + + let audioCommentAction = UIAlertAction(title: "Audio", style: .default) { (_) in + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "AudioCommentViewController") as! AudioCommentViewController + vc.post = self.post + vc.delegate = self + self.navigationController?.pushViewController(vc, animated: true) } - + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) - - alert.addAction(addCommentAction) + alert.addAction(textCommentAction) + alert.addAction(audioCommentAction) alert.addAction(cancelAction) - - present(alert, animated: true, completion: nil) + self.present(alert, animated: true, completion: nil) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -65,24 +87,27 @@ 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 - - return cell + + if comment?.audioURL != nil { + guard let cell = tableView.dequeueReusableCell(withIdentifier: "AudioCell", for: indexPath) as? AudioCommentTableViewCell else { return UITableViewCell() } + cell.recordingURL = comment?.audioURL + cell.usernameLabel.text = comment?.author + return cell + } else { + let cell = tableView.dequeueReusableCell(withIdentifier: "CommentCell", for: indexPath) + + + cell.textLabel?.text = comment?.text + cell.detailTextLabel?.text = comment?.author + + return cell + } + } +} + +extension ImagePostDetailTableViewController: VoiceCommentAddedDelegate { + func reloadData() { + tableView.reloadData() } - - var post: Post! - var postController: PostController! - var imageData: Data? - - - - @IBOutlet weak var imageView: UIImageView! - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var authorLabel: UILabel! - @IBOutlet weak var imageViewAspectRatioConstraint: NSLayoutConstraint! } diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index 93c4b93e..68e7ce24 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -7,15 +7,68 @@ // import UIKit -import Photos +import CoreImage +import CoreImage.CIFilterBuiltins class ImagePostViewController: ShiftableViewController { + + // MARK: - IBOutlets + + @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 blurSlider: UISlider! + @IBOutlet weak var vignetteSlider: UISlider! + @IBOutlet weak var whitePointSlider: UISlider! + @IBOutlet weak var falseColorSlider: UISlider! + @IBOutlet weak var monochromaticSlider: UISlider! + + + // MARK: - Properties + + var postController = PostController.shared + var post: Post? + var imageData: Data? + private let context = CIContext() + + 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() + } + } + + // MARK: - Lifecycles override func viewDidLoad() { super.viewDidLoad() setImageViewHeight(with: 1.0) - updateViews() } @@ -35,9 +88,58 @@ class ImagePostViewController: ShiftableViewController { chooseImageButton.setTitle("", for: []) } + + // MARK: - Functions + + func updateImage() { + if let scaledImage = scaledImage { + imageView.image = image(byFiltering: scaledImage) + } else { + imageView.image = nil + } + } + + private func image(byFiltering image: CIImage) -> UIImage? { + let inputImage = image + + let blurFilter = CIFilter.gaussianBlur() + blurFilter.inputImage = image + blurFilter.radius = blurSlider.value + + + let vignetteFilter = CIFilter.vignette() + vignetteFilter.inputImage = blurFilter.outputImage?.clampedToExtent() + vignetteFilter.intensity = vignetteSlider.value * 5 + vignetteFilter.radius = vignetteSlider.value * 50 + + let sepiaFilter = CIFilter.sepiaTone() + sepiaFilter.inputImage = vignetteFilter.outputImage?.clampedToExtent() + sepiaFilter.intensity = whitePointSlider.value + + let sharpenFilter = CIFilter.sharpenLuminance() + sharpenFilter.inputImage = sepiaFilter.outputImage?.clampedToExtent() + sharpenFilter.sharpness = falseColorSlider.value + + let monochromaticFilter = CIFilter.colorMonochrome() + monochromaticFilter.inputImage = sepiaFilter.outputImage?.clampedToExtent() + monochromaticFilter.intensity = monochromaticSlider.value + + guard let outputImage = monochromaticFilter.outputImage else { return nil } + + guard let renderedCGImage = context.createCGImage(outputImage, from: inputImage.extent) else { return nil } + + return UIImage(cgImage: renderedCGImage) + } + func setImageViewHeight(with aspectRatio: CGFloat) { + + imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio + + view.layoutSubviews() + } + private func presentImagePickerController() { - + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { presentInformationalAlertController(title: "Error", message: "The photo library is unavailable") return @@ -62,7 +164,7 @@ class ImagePostViewController: ShiftableViewController { return } - postController.createPost(with: title, ofType: .image, mediaData: imageData, ratio: imageView.image?.ratio) { (success) in + postController.createImagePost(with: title, ofType: .image, mediaData: imageData, ratio: imageView.image?.ratio) { (success) in guard success else { DispatchQueue.main.async { self.presentInformationalAlertController(title: "Error", message: "Unable to create post. Try again.") @@ -75,7 +177,33 @@ class ImagePostViewController: ShiftableViewController { } } } - + +// DispatchQueue.main.async { +// let imagePicker = UIImagePickerController() +// imagePicker.delegate = self +// imagePicker.sourceType = .photoLibrary +// +// self.present(imagePicker, animated: true, completion: nil) +// } +// } + + // MARK: - IBActions + +// @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, audioURL: nil) +// +// navigationController?.popViewController(animated: true) +// } + @IBAction func chooseImage(_ sender: Any) { let authorizationStatus = PHPhotoLibrary.authorizationStatus() @@ -86,43 +214,45 @@ class ImagePostViewController: ShiftableViewController { 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") + default: + break } presentImagePickerController() } + - func setImageViewHeight(with aspectRatio: CGFloat) { - - imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio - - view.layoutSubviews() + @IBAction func blurSliderUpdated(_ sender: Any) { + updateImage() + } + + @IBAction func vignetteSliderUpdated(_ sender: Any) { + updateImage() + } + + @IBAction func sepiaSliderUpdated(_ sender: Any) { + updateImage() + } + + @IBAction func sharpenSliderUpdated(_ sender: Any) { + updateImage() + } + + @IBAction func monochromaticSliderUpdated(_ sender: Any) { + updateImage() } - - var postController: PostController! - var post: Post? - var imageData: Data? - - @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 { @@ -130,7 +260,13 @@ extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigation func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { chooseImageButton.setTitle("", for: []) - + + if let image = info[.editedImage] as? UIImage { + originalImage = image + } else if let image = info[.originalImage] as? UIImage { + originalImage = image + } + picker.dismiss(animated: true, completion: nil) guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } diff --git a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift index 7a8191b4..6f6c1e35 100644 --- a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift +++ b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift @@ -7,29 +7,14 @@ // import UIKit -import FirebaseAuth -import FirebaseUI class PostsCollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout { - override func viewDidLoad() { - super.viewDidLoad() - - postController.observePosts { (_) in - DispatchQueue.main.async { - self.collectionView.reloadData() - } - } - } + let postController = PostController.shared - @IBAction func signout(_ sender: Any) { - let firebaseAuth = Auth.auth() - do { - try firebaseAuth.signOut() - } catch let signOutError as NSError { - print ("Error signing out: %@", signOutError) - } - dismiss(animated: true, completion: nil) + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + collectionView.reloadData() } @IBAction func addPost(_ sender: Any) { @@ -55,6 +40,7 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie } override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let post = postController.posts[indexPath.row] switch post.mediaType { @@ -63,9 +49,9 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImagePostCell", for: indexPath) as? ImagePostCollectionViewCell else { return UICollectionViewCell() } cell.post = post - + loadImage(for: cell, forItemAt: indexPath) - + return cell } } @@ -76,6 +62,7 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie let post = postController.posts[indexPath.row] + switch post.mediaType { case .image: @@ -84,11 +71,9 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie size.height = size.width * ratio } - return size } - - + override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let cell = collectionView.cellForItem(at: indexPath) @@ -97,83 +82,22 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie self.performSegue(withIdentifier: "ViewImagePost", sender: nil) } } - - override func collectionView(_ collectionView: UICollectionView, didEndDisplayingSupplementaryView view: UICollectionReusableView, forElementOfKind elementKind: String, at indexPath: IndexPath) { - - guard let postID = postController.posts[indexPath.row].id else { return } - operations[postID]?.cancel() - } - - func loadImage(for imagePostCell: ImagePostCollectionViewCell, forItemAt indexPath: IndexPath) { - let post = postController.posts[indexPath.row] - - guard let postID = post.id else { return } - - if let mediaData = cache.value(for: postID), - let image = UIImage(data: mediaData) { - imagePostCell.setImage(image) - self.collectionView.reloadItems(at: [indexPath]) - return - } - - let fetchOp = FetchMediaOperation(post: post, postController: postController) - - let cacheOp = BlockOperation { - if let data = fetchOp.mediaData { - self.cache.cache(value: data, for: postID) - DispatchQueue.main.async { - self.collectionView.reloadItems(at: [indexPath]) - } - } - } - - let completionOp = BlockOperation { - defer { self.operations.removeValue(forKey: postID) } - - if let currentIndexPath = self.collectionView?.indexPath(for: imagePostCell), - currentIndexPath != indexPath { - print("Got image for now-reused cell") - return - } - - if let data = fetchOp.mediaData { - imagePostCell.setImage(UIImage(data: data)) - self.collectionView.reloadItems(at: [indexPath]) - } - } - - cacheOp.addDependency(fetchOp) - completionOp.addDependency(fetchOp) - - mediaFetchQueue.addOperation(fetchOp) - mediaFetchQueue.addOperation(cacheOp) - OperationQueue.main.addOperation(completionOp) - - operations[postID] = fetchOp - } + // 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 + + _ = segue.destination as? ImagePostViewController } else if segue.identifier == "ViewImagePost" { let destinationVC = segue.destination as? ImagePostDetailTableViewController + + guard let indexPath = collectionView.indexPathsForSelectedItems?.first else { return } - guard let indexPath = collectionView.indexPathsForSelectedItems?.first, - let postID = postController.posts[indexPath.row].id else { return } - - destinationVC?.postController = postController destinationVC?.post = postController.posts[indexPath.row] - destinationVC?.imageData = cache.value(for: postID) } } - - private let postController = PostController() - private var operations = [String : Operation]() - private let mediaFetchQueue = OperationQueue() - private let cache = Cache() } diff --git a/LambdaTimeline/View Controllers/SignInViewController.swift b/LambdaTimeline/View Controllers/SignInViewController.swift index 5b44b832..b4332897 100644 --- a/LambdaTimeline/View Controllers/SignInViewController.swift +++ b/LambdaTimeline/View Controllers/SignInViewController.swift @@ -7,70 +7,36 @@ // import UIKit -import Firebase -import GoogleSignIn class SignInViewController: UIViewController { - + + @IBOutlet weak var nameTextField: UITextField! + + let postController = PostController() + override func viewDidLoad() { super.viewDidLoad() - GIDSignIn.sharedInstance()?.presentingViewController = self - GIDSignIn.sharedInstance()?.delegate = self - - setUpSignInButton() } - @IBAction func googleSignIn(_ sender: Any) { - GIDSignIn.sharedInstance()?.signIn() + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + segueIfUsernameExists() } -} - -extension SignInViewController: GIDSignInDelegate { - func sign(_ signIn: GIDSignIn!, didSignInFor user: GIDGoogleUser!, withError error: Error!) { - - if let error = error { - print("Error signing in with Google: \(error)") - return - } - - guard let authentication = user.authentication else { return } - - let credential = GoogleAuthProvider.credential(withIDToken: authentication.idToken, accessToken: authentication.accessToken) - - Auth.auth().signIn(with: credential) { (authResult, error) in - if let error = error { - print("Error signing in with Google: \(error)") - return - } - - DispatchQueue.main.async { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let postsNavigationController = storyboard.instantiateViewController(withIdentifier: "PostsNavigationController") - postsNavigationController.modalPresentationStyle = .fullScreen - self.present(postsNavigationController, animated: true, completion: nil) - } - } + + @IBAction func getStarted(_ sender: Any) { + UserDefaults.standard.set(nameTextField.text, forKey: "username") + segueIfUsernameExists() } - func sign(_ signIn: GIDSignIn!, didDisconnectWith user: GIDGoogleUser!, withError error: Error!) { - print("User disconnected") + func segueIfUsernameExists() { + if UserDefaults.standard.string(forKey: "username") != nil { + performSegue(withIdentifier: "ModalPostsVC", sender: nil) + } } - func setUpSignInButton() { - - let button = GIDSignInButton() - - button.translatesAutoresizingMaskIntoConstraints = false - - view.addSubview(button) - - - let buttonCenterXConstraint = button.centerXAnchor.constraint(equalTo: view.centerXAnchor) - let buttonCenterYConstraint = button.centerYAnchor.constraint(equalTo: view.centerYAnchor) - let buttonWidthConstraint = button.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: 0.5) - - view.addConstraints([buttonCenterXConstraint, - buttonCenterYConstraint, - buttonWidthConstraint]) + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "ModalPostsVC" { + guard ((segue.destination as? UINavigationController)?.topViewController as? PostsCollectionViewController) != nil else { return } + } } } diff --git a/LambdaTimeline/Views/ImagePostCollectionViewCell.swift b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift index 3841cf65..fb81265b 100644 --- a/LambdaTimeline/Views/ImagePostCollectionViewCell.swift +++ b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift @@ -10,10 +10,24 @@ 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() @@ -23,32 +37,20 @@ class ImagePostCollectionViewCell: UICollectionViewCell { } func updateViews() { - guard let post = post else { return } - + guard let post = post, + case MediaType.image(let image) = post.mediaType else { return } + titleLabel.text = post.title - authorLabel.text = post.author.displayName + authorLabel.text = post.author + imageView.image = image } func setupLabelBackgroundView() { labelBackgroundView.layer.cornerRadius = 8 -// labelBackgroundView.layer.borderColor = UIColor.white.cgColor -// labelBackgroundView.layer.borderWidth = 0.5 labelBackgroundView.clipsToBounds = true } func setImage(_ image: UIImage?) { imageView.image = image } - - var post: Post? { - didSet { - updateViews() - } - } - -@IBOutlet weak var imageView: UIImageView! -@IBOutlet weak var titleLabel: UILabel! -@IBOutlet weak var authorLabel: UILabel! -@IBOutlet weak var labelBackgroundView: UIView! - } diff --git a/README.md b/README.md index adfee3d4..e1a2ea4e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,15 @@ At this point, run the app on your simulator or physical device in order to make 1. Integrate your `ImagePostViewController` into the Lambda Timeline Firebase project. 2. You should be able to create a new post, select a photo, edit the photo, and post it to your Firebase server. +3. Ensure that the controls to add your filters, adjust them, etc. are only available to the user at the appropriate time. + 1. For example, you shouldn't let the user add a filter if they haven't selected an image yet. And it doesn't make sense to show the adjustment UI if they selected a filter that has no adjustment. +4. You can use a Collection or Table View to select filters with additional detail screens to fine tune amounts of filters (i.e.: Instagram or Apple Photo) + +### Part 2: Feature Integration + +1. Integrate your `ImagePostViewController` into the Lambda Timeline project. +2. You should be able to create a new post, select a photo, edit the photo, and create an image post. +3. Be sure that you are able to add comments to posts. ## Go Further From 8c4d15202cdb20b203bef58922befa98c09bc7e9 Mon Sep 17 00:00:00 2001 From: Libby Thomas Date: Wed, 9 Sep 2020 19:05:41 -0500 Subject: [PATCH 2/2] MVP Day 2 --- .../AudioCommentViewController.swift | 35 ++++-------- .../ImagePostDetailTableViewController.swift | 5 +- .../ImagePostViewController.swift | 53 +++---------------- .../PostsCollectionViewController.swift | 4 +- 4 files changed, 21 insertions(+), 76 deletions(-) diff --git a/LambdaTimeline/View Controllers/AudioCommentViewController.swift b/LambdaTimeline/View Controllers/AudioCommentViewController.swift index 8cc8939c..617c0ae3 100644 --- a/LambdaTimeline/View Controllers/AudioCommentViewController.swift +++ b/LambdaTimeline/View Controllers/AudioCommentViewController.swift @@ -169,24 +169,14 @@ class AudioCommentViewController: UIViewController { audioRecorder?.isRecording ?? false } -// func createNewRecordingURL() -> URL { -// let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! -// -// let file = documents.appendingPathComponent("Audio Comment" + "\(Date())", isDirectory: false).appendingPathExtension("caf") -// -// print("recording URL: \(file)") -// -// return file -// } + func newRecordingURL() -> URL { + let fm = FileManager.default + let documentsDir = try! fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - func newRecordingURL() -> URL { - let fm = FileManager.default - let documentsDir = try! fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let randomId = Int.random(in: 0...1_000_00) - // let randomNumber = Int.random(in: 0...1_000_00) - - return documentsDir.appendingPathComponent("TestRecording").appendingPathExtension("caf") - } + return documentsDir.appendingPathComponent("TestRecording" + "\(randomId)").appendingPathExtension("caf") + } func requestPermissionOrStartRecording() { switch AVAudioSession.sharedInstance().recordPermission { @@ -269,8 +259,7 @@ class AudioCommentViewController: UIViewController { } @IBAction func saveRecording(_ sender: Any) { - let commentURL = newRecordingURL() - self.postController.addAudioComment(with: commentURL, to: &self.post) + self.postController.addAudioComment(with: recordingURL!, to: &self.post) self.delegate?.reloadData() navigationController?.popToRootViewController(animated: true) @@ -286,9 +275,6 @@ class AudioCommentViewController: UIViewController { } } - - - extension AudioCommentViewController: AVAudioPlayerDelegate { func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { @@ -306,19 +292,16 @@ extension AudioCommentViewController: AVAudioPlayerDelegate { extension AudioCommentViewController: 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 Recorder Error: \(error)") + if let error = error{ + print("Recoder Player Error: \(error)") } } - } diff --git a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift index e249b90f..5055820b 100644 --- a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift @@ -91,13 +91,16 @@ class ImagePostDetailTableViewController: UITableViewController { if comment?.audioURL != nil { guard let cell = tableView.dequeueReusableCell(withIdentifier: "AudioCell", for: indexPath) as? AudioCommentTableViewCell else { return UITableViewCell() } + + let comment = post?.comments[indexPath.row + 1] cell.recordingURL = comment?.audioURL cell.usernameLabel.text = comment?.author + return cell } else { 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 diff --git a/LambdaTimeline/View Controllers/ImagePostViewController.swift b/LambdaTimeline/View Controllers/ImagePostViewController.swift index 68e7ce24..49b78bdc 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import Photos import CoreImage import CoreImage.CIFilterBuiltins @@ -30,7 +31,7 @@ class ImagePostViewController: ShiftableViewController { // MARK: - Properties - var postController = PostController.shared + let postController = PostController.shared var post: Post? var imageData: Data? private let context = CIContext() @@ -69,7 +70,7 @@ class ImagePostViewController: ShiftableViewController { super.viewDidLoad() setImageViewHeight(with: 1.0) - updateViews() + originalImage = imageView.image } func updateViews() { @@ -158,52 +159,18 @@ class ImagePostViewController: ShiftableViewController { view.endEditing(true) - guard let imageData = imageView.image?.jpegData(compressionQuality: 0.1), + 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, ofType: .image, mediaData: imageData, ratio: imageView.image?.ratio) { (success) in - guard success else { - DispatchQueue.main.async { - self.presentInformationalAlertController(title: "Error", message: "Unable to create post. Try again.") - } - return - } - - DispatchQueue.main.async { - self.navigationController?.popViewController(animated: true) - } - } - } -// DispatchQueue.main.async { -// let imagePicker = UIImagePickerController() -// imagePicker.delegate = self -// imagePicker.sourceType = .photoLibrary -// -// self.present(imagePicker, animated: true, completion: nil) -// } -// } + postController.createImagePost(with: title, image: image, ratio: image.ratio, audioURL: nil) + navigationController?.popViewController(animated: true) + } // MARK: - IBActions -// @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, audioURL: nil) -// -// navigationController?.popViewController(animated: true) -// } - @IBAction func chooseImage(_ sender: Any) { let authorizationStatus = PHPhotoLibrary.authorizationStatus() @@ -268,12 +235,6 @@ extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigation } picker.dismiss(animated: true, completion: nil) - - guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } - - imageView.image = image - - setImageViewHeight(with: image.ratio) } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { diff --git a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift index 6f6c1e35..87061cdd 100644 --- a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift +++ b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift @@ -49,9 +49,7 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImagePostCell", for: indexPath) as? ImagePostCollectionViewCell else { return UICollectionViewCell() } cell.post = post - - loadImage(for: cell, forItemAt: indexPath) - + return cell } }