diff --git a/LambdaTimeline.xcodeproj/project.pbxproj b/LambdaTimeline.xcodeproj/project.pbxproj index a8d63c01..9c838daa 100644 --- a/LambdaTimeline.xcodeproj/project.pbxproj +++ b/LambdaTimeline.xcodeproj/project.pbxproj @@ -8,58 +8,54 @@ /* Begin PBXBuildFile section */ 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4646377B216FDE4B00E7FF73 /* AppDelegate.swift */; }; - 46463781216FDE4B00E7FF73 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4646377F216FDE4B00E7FF73 /* Main.storyboard */; }; 46463783216FDE4C00E7FF73 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46463782216FDE4C00E7FF73 /* Assets.xcassets */; }; 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 */; }; + 612010F9256258D600F7543C /* PostMapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612010F8256258D600F7543C /* PostMapViewController.swift */; }; + 612010FC25625B9400F7543C /* PostAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 612010FB25625B9400F7543C /* PostAnnotation.swift */; }; + 61311928255BC5500054D457 /* CameraViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61311927255BC5500054D457 /* CameraViewController.swift */; }; + 6131192B255BC5C40054D457 /* CameraPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6131192A255BC5C40054D457 /* CameraPreviewView.swift */; }; + 6131192E255BC5E90054D457 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6131192D255BC5E90054D457 /* VideoPlayerView.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 */ - 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; }; + 612010F8256258D600F7543C /* PostMapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostMapViewController.swift; sourceTree = ""; }; + 612010FB25625B9400F7543C /* PostAnnotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostAnnotation.swift; sourceTree = ""; }; + 61311927255BC5500054D457 /* CameraViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraViewController.swift; sourceTree = ""; }; + 6131192A255BC5C40054D457 /* CameraPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewView.swift; sourceTree = ""; }; + 6131192D255BC5E90054D457 /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.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 */ @@ -67,29 +63,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 = ""; }; @@ -119,8 +103,8 @@ isa = PBXGroup; children = ( 4646378F216FFD1B00E7FF73 /* Post.swift */, - 46CFE6FA21714E6100E7FF73 /* Author.swift */, 46463791216FFDD900E7FF73 /* Comment.swift */, + 612010FB25625B9400F7543C /* PostAnnotation.swift */, ); path = Models; sourceTree = ""; @@ -138,7 +122,7 @@ 46463797216FFF7400E7FF73 /* Storyboards */ = { isa = PBXGroup; children = ( - 4646377F216FDE4B00E7FF73 /* Main.storyboard */, + 46D1A48824FF0BC4008D1CA7 /* Main.storyboard */, 46463784216FDE4C00E7FF73 /* LaunchScreen.storyboard */, ); path = Storyboards; @@ -159,6 +143,9 @@ 46A0366921700F5100E7FF73 /* PostsCollectionViewController.swift */, 46CFE6F421707D0000E7FF73 /* ImagePostViewController.swift */, 46D571F72173FC2700E7FF73 /* ImagePostDetailTableViewController.swift */, + 61736DE2255A83D1005112BE /* AudioRecorderController.swift */, + 61311927255BC5500054D457 /* CameraViewController.swift */, + 612010F8256258D600F7543C /* PostMapViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -167,27 +154,17 @@ isa = PBXGroup; children = ( 46CFE7022171572600E7FF73 /* ImagePostCollectionViewCell.swift */, + 61736DE5255A8485005112BE /* AudioVisualizer.swift */, + 6131192A255BC5C40054D457 /* CameraPreviewView.swift */, + 6131192D255BC5E90054D457 /* VideoPlayerView.swift */, ); 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 */, ); path = Helpers; @@ -196,21 +173,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 +186,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 = ( ); @@ -246,6 +211,7 @@ TargetAttributes = { 46463777216FDE4B00E7FF73 = { CreatedOnToolsVersion = 10.0; + LastSwiftMigration = 1130; }; }; }; @@ -272,119 +238,36 @@ 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 = ( - ); - inputPaths = ( - "${SRCROOT}/Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-resources.sh", - "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseUI/FirebaseAuthUI.bundle", - "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseUI/FirebaseGoogleAuthUI.bundle", - "${PODS_ROOT}/GoogleSignIn/Resources/GoogleSignIn.bundle", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - ); - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseAuthUI.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseGoogleAuthUI.bundle", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 931381193B5D2856F9B0CA8C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${SRCROOT}/Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/GTMOAuth2/GTMOAuth2.framework", - "${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework", - "${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework", - "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", - "${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework", - "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - ); - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMOAuth2.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-LambdaTimeline/Pods-LambdaTimeline-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 46463774216FDE4B00E7FF73 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 61736DE6255A8485005112BE /* AudioVisualizer.swift in Sources */, 46A0366D2170158900E7FF73 /* SignInViewController.swift in Sources */, - 46CFE6FB21714E6100E7FF73 /* Author.swift in Sources */, 46CFE6F92170862C00E7FF73 /* ShiftableViewController.swift in Sources */, 4646377C216FDE4B00E7FF73 /* AppDelegate.swift in Sources */, - 46CFE6FD217154F500E7FF73 /* Networking.swift in Sources */, + 6131192E255BC5E90054D457 /* VideoPlayerView.swift in Sources */, 46CFE6F521707D0000E7FF73 /* ImagePostViewController.swift in Sources */, - 46CFE6FF2171556D00E7FF73 /* ConcurrentOperation.swift in Sources */, 46463790216FFD1B00E7FF73 /* Post.swift in Sources */, - 46CFE6F32170757F00E7FF73 /* User+DictionaryRepresentation.swift in Sources */, - 46D571F32172D43B00E7FF73 /* Cache.swift in Sources */, - 46CFE7012171559500E7FF73 /* FetchMediaOperation.swift in Sources */, + 6131192B255BC5C40054D457 /* CameraPreviewView.swift in Sources */, + 61736DE3255A83D1005112BE /* AudioRecorderController.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 */, + 612010FC25625B9400F7543C /* PostAnnotation.swift in Sources */, 46D571F52173CF3E00E7FF73 /* UIImage+Ratio.swift in Sources */, + 61311928255BC5500054D457 /* CameraViewController.swift in Sources */, + 612010F9256258D600F7543C /* PostMapViewController.swift in Sources */, 46463792216FFDD900E7FF73 /* Comment.swift in Sources */, 46CFE6F721707FA600E7FF73 /* UIViewController+InformationalAlert.swift in Sources */, ); @@ -393,14 +276,6 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ - 4646377F216FDE4B00E7FF73 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 46463780216FDE4B00E7FF73 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; 46463784216FDE4C00E7FF73 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -530,36 +405,38 @@ }; 4646378B216FDE4C00E7FF73 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 33A2B89DE9E21538434BD640 /* Pods-LambdaTimeline.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Z7LY99W88; INFOPLIST_FILE = "$(SRCROOT)/LambdaTimeline/Resources/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = com.omihek.LambdaTimeline; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; 4646378C216FDE4C00E7FF73 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5D2CCD6C68779B0A70AC37FA /* Pods-LambdaTimeline.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = 5Z7LY99W88; INFOPLIST_FILE = "$(SRCROOT)/LambdaTimeline/Resources/Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + PRODUCT_BUNDLE_IDENTIFIER = com.omihek.LambdaTimeline; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; 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/Cache.swift b/LambdaTimeline/Helpers/Cache.swift deleted file mode 100644 index 42996051..00000000 --- a/LambdaTimeline/Helpers/Cache.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Cache.swift -// LambdaTimeline -// -// Created by Spencer Curtis on 10/13/18. -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation - -class Cache { - - func cache(value: Value, for key: Key) { - queue.async { - self.cache[key] = value - } - } - - func value(for key: Key) -> Value? { - return queue.sync { cache[key] } - } - - private var cache = [Key : Value]() - private let queue = DispatchQueue(label: "com.LambdaSchool.LambdaTimeline.CacheQueue") -} diff --git a/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift b/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift index 79bb32d7..bed22518 100644 --- a/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift +++ b/LambdaTimeline/Helpers/Extensions/UIImage+Ratio.swift @@ -12,4 +12,34 @@ extension UIImage { var ratio: CGFloat { return size.height / size.width } + + var vidRatio: CGFloat { + return (size.height / 2) / 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/User+DictionaryRepresentation.swift b/LambdaTimeline/Helpers/Extensions/User+DictionaryRepresentation.swift deleted file mode 100644 index 7740ab89..00000000 --- a/LambdaTimeline/Helpers/Extensions/User+DictionaryRepresentation.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// User+DictionaryRepresentation.swift -// LambdaTimeline -// -// Created by Spencer Curtis on 10/12/18. -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation -import FirebaseAuth - -extension User { - - private static let uidKey = "uid" - private static let displayNameKey = "displayName" - - var dictionaryRepresentation: [String: String] { - return [User.uidKey: uid, - User.displayNameKey: displayName ?? "No display name"] - } -} diff --git a/LambdaTimeline/Helpers/FirebaseConvertible.swift b/LambdaTimeline/Helpers/FirebaseConvertible.swift deleted file mode 100644 index a3608644..00000000 --- a/LambdaTimeline/Helpers/FirebaseConvertible.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// FirebaseConvertible.swift -// LambdaTimeline -// -// Created by Spencer Curtis on 10/11/18. -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation - -protocol FirebaseConvertible { - var dictionaryRepresentation: [String: Any] { get } -} diff --git a/LambdaTimeline/Helpers/Networking.swift b/LambdaTimeline/Helpers/Networking.swift deleted file mode 100644 index 90e171bf..00000000 --- a/LambdaTimeline/Helpers/Networking.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// Networking.swift -// LambdaTimeline -// -// Created by Spencer Curtis on 10/12/18. -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation - -enum HTTPMethod: String { - case get = "GET" - case post = "POST" - case put = "PUT" - case patch = "PATCH" - case delete = "DELETE" -} - -enum Networking { - - static func performRequestFor(url: URL, httpMethod: HTTPMethod, parameters: [String: String]? = nil, headers: [String: String]? = nil, body: Data? = nil, completion: @escaping (Data?, URLResponse?, Error?) -> Void) { - - var formattedURL: URL? = url - - if let parameters = parameters { formattedURL = format(url: url, with: parameters) } - - guard let requestURL = formattedURL else { fatalError("requestURL is nil") } - - var request = URLRequest(url: requestURL) - - request.httpMethod = httpMethod.rawValue - request.httpBody = body - - if let headers = headers { - headers.forEach { (key, value) in - request.setValue(value, forHTTPHeaderField: key) - } - } - - URLSession.shared.dataTask(with: request, completionHandler: completion).resume() - } - - static private func format(url: URL, with queryParameters: [String: String]) -> URL? { - - var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) - - urlComponents?.queryItems = queryParameters.compactMap({ URLQueryItem(name: $0.key, value: $0.value) }) - - return urlComponents?.url ?? nil - } -} diff --git a/LambdaTimeline/Helpers/Operations/ConcurrentOperation.swift b/LambdaTimeline/Helpers/Operations/ConcurrentOperation.swift deleted file mode 100644 index 53121118..00000000 --- a/LambdaTimeline/Helpers/Operations/ConcurrentOperation.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// ConcurrentOperation.swift -// LambdaTimeline -// -// Created by Andrew Madsen -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation - -class ConcurrentOperation: Operation { - - // MARK: Types - - enum State: String { - case isReady, isExecuting, isFinished - } - - // MARK: Properties - - private var _state = State.isReady - - private let stateQueue = DispatchQueue(label: "com.LambdaSchool.Astronomy.ConcurrentOperationStateQueue") - var state: State { - get { - var result: State? - let queue = self.stateQueue - queue.sync { - result = _state - } - return result! - } - - set { - let oldValue = state - willChangeValue(forKey: newValue.rawValue) - willChangeValue(forKey: oldValue.rawValue) - - stateQueue.sync { self._state = newValue } - - didChangeValue(forKey: oldValue.rawValue) - didChangeValue(forKey: newValue.rawValue) - } - } - - // MARK: NSOperation - - override dynamic var isReady: Bool { - return super.isReady && state == .isReady - } - - override dynamic var isExecuting: Bool { - return state == .isExecuting - } - - override dynamic var isFinished: Bool { - return state == .isFinished - } - - override var isAsynchronous: Bool { - return true - } - -} diff --git a/LambdaTimeline/Helpers/Operations/FetchMediaOperation.swift b/LambdaTimeline/Helpers/Operations/FetchMediaOperation.swift deleted file mode 100644 index 7632555b..00000000 --- a/LambdaTimeline/Helpers/Operations/FetchMediaOperation.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// FetchMediaOperation.swift -// LambdaTimeline -// -// Created by Spencer Curtis on 10/12/18. -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation - -class FetchMediaOperation: ConcurrentOperation { - - init(post: Post, postController: PostController, session: URLSession = URLSession.shared) { - self.post = post - self.postController = postController - self.session = session - super.init() - } - - override func start() { - state = .isExecuting - - let url = post.mediaURL - - let task = session.dataTask(with: url) { (data, response, error) in - defer { self.state = .isFinished } - if self.isCancelled { return } - if let error = error { - NSLog("Error fetching data for \(self.post): \(error)") - return - } - - guard let data = data else { - NSLog("No data returned from fetch media operation data task.") - return - } - - self.mediaData = data - } - task.resume() - dataTask = task - } - - override func cancel() { - dataTask?.cancel() - super.cancel() - } - - // MARK: Properties - - let post: Post - let postController: PostController - var mediaData: Data? - - private let session: URLSession - - private var dataTask: URLSessionDataTask? - -} diff --git a/LambdaTimeline/Model Controllers/PostController.swift b/LambdaTimeline/Model Controllers/PostController.swift index 29ce3869..6db3cb71 100644 --- a/LambdaTimeline/Model Controllers/PostController.swift +++ b/LambdaTimeline/Model Controllers/PostController.swift @@ -6,122 +6,53 @@ // Copyright © 2018 Lambda School. All rights reserved. // -import Foundation -import FirebaseAuth -import FirebaseDatabase -import FirebaseStorage +import UIKit +import MapKit class PostController { - func createPost(with title: String, ofType mediaType: MediaType, mediaData: Data, ratio: CGFloat? = nil, completion: @escaping (Bool) -> Void = { _ in }) { - - guard let currentUser = Auth.auth().currentUser, - let author = Author(user: 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) - } - - completion(true) - } - } + var posts: [Post] = [] + var mapPosts: [PostAnnotation] = [] + + var currentUser: String? { + UserDefaults.standard.string(forKey: "username") } - func addComment(with text: String, to post: inout Post) { + func createImagePost(with title: String, image: UIImage, ratio: CGFloat?, geotag: CLLocationCoordinate2D? = nil) { + guard let currentUser = currentUser else { return } - guard let currentUser = Auth.auth().currentUser, - let author = Author(user: currentUser) else { return } + let post = Post(title: title, mediaType: .image(image), ratio: ratio, author: currentUser, geotag: geotag) - let comment = Comment(text: text, author: author) - 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)") - } + posts.append(post) } - func savePostToFirebase(_ post: Post, completion: (Error?) -> Void = { _ in }) { - - guard let postID = post.id else { return } + func createVideoPost(with title: String, image: UIImage, video: URL, ratio: CGFloat?, geotag: CLLocationCoordinate2D? = nil) { + guard let currentUser = currentUser else { return } - let ref = postsRef.child(postID) + let post = Post(title: title, mediaType: .video(video), frameCap: image, ratio: ratio, author: currentUser, geotag: geotag) - ref.setValue(post.dictionaryRepresentation) + posts.append(post) } - - private func store(mediaData: Data, mediaType: MediaType, completion: @escaping (URL?) -> Void) { - - let mediaID = UUID().uuidString - - let mediaRef = storageRef.child(mediaType.rawValue).child(mediaID) + + func createMapPost(with title: String, location: CLLocationCoordinate2D) { + guard let currentUser = currentUser else { return } - 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) - }) - } + let mapPost = PostAnnotation(coordinate: location, title: title, subtitle: currentUser) - uploadTask.resume() + mapPosts.append(mapPost) } - var posts: [Post] = [] - let currentUser = Auth.auth().currentUser - let postsRef = Database.database().reference().child("posts") - - let storageRef = Storage.storage().reference() - + 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/Author.swift b/LambdaTimeline/Models/Author.swift deleted file mode 100644 index 4d847ad1..00000000 --- a/LambdaTimeline/Models/Author.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// Author.swift -// LambdaTimeline -// -// Created by Spencer Curtis on 10/12/18. -// Copyright © 2018 Lambda School. All rights reserved. -// - -import Foundation -import FirebaseAuth - -struct Author: FirebaseConvertible, Equatable { - - init?(user: User) { - self.init(dictionary: user.dictionaryRepresentation) - } - - init?(dictionary: [String: Any]) { - guard let uid = dictionary[Author.uidKey] as? String, - let displayName = dictionary[Author.displayNameKey] as? String else { return nil } - - self.uid = uid - self.displayName = displayName - } - - let uid: String - let displayName: String? - - private static let uidKey = "uid" - private static let displayNameKey = "displayName" - - var dictionaryRepresentation: [String: Any] { - return [Author.uidKey: uid, - Author.displayNameKey: displayName ?? "No display name"] - } -} diff --git a/LambdaTimeline/Models/Comment.swift b/LambdaTimeline/Models/Comment.swift index cbbf4304..4d9dc72f 100644 --- a/LambdaTimeline/Models/Comment.swift +++ b/LambdaTimeline/Models/Comment.swift @@ -7,38 +7,31 @@ // import Foundation -import FirebaseAuth -struct 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? = nil) { 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) + func hash(into hasher: inout Hasher) { + hasher.combine(timestamp.hashValue ^ author.hashValue) } - - var dictionaryRepresentation: [String: Any] { - return [Comment.textKey: text, - Comment.author: author.dictionaryRepresentation, - Comment.timestampKey: timestamp.timeIntervalSince1970] + + 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 00cad0f2..dad7c25d 100644 --- a/LambdaTimeline/Models/Post.swift +++ b/LambdaTimeline/Models/Post.swift @@ -6,74 +6,41 @@ // Copyright © 2018 Lambda School. All rights reserved. // -import Foundation -import FirebaseAuth +import UIKit +import MapKit -enum MediaType: String { - case image +enum MediaType { + case image(UIImage) + case video(URL) } -struct 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 frameCap: UIImage? + let author: String let timestamp: Date var comments: [Comment] - var id: String? var ratio: CGFloat? + var id: String? + var geotag: CLLocationCoordinate2D? var title: String? { return comments.first?.text } - 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, frameCap: UIImage? = nil, ratio: CGFloat?, author: String, timestamp: Date = Date(), geotag: CLLocationCoordinate2D?) { + self.mediaType = mediaType + self.frameCap = frameCap + self.ratio = ratio + self.author = author + self.comments = [Comment(text: title, author: author)] + self.timestamp = timestamp + self.geotag = geotag + self.id = UUID().uuidString + } + + static func ==(lhs: Post, rhs: Post) -> Bool { + return lhs.id == rhs.id + } } diff --git a/LambdaTimeline/Models/PostAnnotation.swift b/LambdaTimeline/Models/PostAnnotation.swift new file mode 100644 index 00000000..fd2d97f3 --- /dev/null +++ b/LambdaTimeline/Models/PostAnnotation.swift @@ -0,0 +1,22 @@ +// +// PostAnnotation.swift +// LambdaTimeline +// +// Created by Kenneth Jones on 11/16/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import Foundation +import MapKit + +class PostAnnotation: NSObject, MKAnnotation { + var coordinate: CLLocationCoordinate2D + var title: String? + var subtitle: String? + + init(coordinate: CLLocationCoordinate2D, title: String, subtitle: String) { + self.coordinate = coordinate + self.title = title + self.subtitle = subtitle + } +} diff --git a/LambdaTimeline/Resources/AppDelegate.swift b/LambdaTimeline/Resources/AppDelegate.swift index 057832b1..a44cc8ca 100644 --- a/LambdaTimeline/Resources/AppDelegate.swift +++ b/LambdaTimeline/Resources/AppDelegate.swift @@ -7,35 +7,14 @@ // import UIKit -import Firebase -import GoogleSignIn @UIApplicationMain 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 application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - return GIDSignIn.sharedInstance().handle(url, - sourceApplication:options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String, - annotation: [:]) - } } diff --git a/LambdaTimeline/Resources/Info.plist b/LambdaTimeline/Resources/Info.plist index f35b24fa..49d5e332 100644 --- a/LambdaTimeline/Resources/Info.plist +++ b/LambdaTimeline/Resources/Info.plist @@ -23,7 +23,7 @@ Editor CFBundleURLSchemes - com.googleusercontent.apps.873272785897-7rlq5dqbnu9sdhg6nqhl4cn3drnl7uad + Copy-paste-your-REVERSED_CLIENT_ID-from-GoogleService-Info.plist @@ -31,6 +31,10 @@ 1 LSRequiresIPhoneOS + NSCameraUsageDescription + $(PRODUCT_NAME) needs to access your camera to allow you to record video. + NSMicrophoneUsageDescription + $(PRODUCT_NAME) needs to access your microphone to allow you to record audio comments. NSPhotoLibraryUsageDescription In order to allow you to add photos to posts UILaunchStoryboardName @@ -54,5 +58,7 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSLocationWhenInUseUsageDescription + $(PRODUCT_NAME) will need your location if you want to add it to your posts. diff --git a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard b/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard deleted file mode 100644 index 7c1134e0..00000000 --- a/LambdaTimeline/Storyboards/Base.lproj/Main.storyboard +++ /dev/null @@ -1,354 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LambdaTimeline/Storyboards/Main.storyboard b/LambdaTimeline/Storyboards/Main.storyboard new file mode 100644 index 00000000..b2a17c09 --- /dev/null +++ b/LambdaTimeline/Storyboards/Main.storyboard @@ -0,0 +1,740 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LambdaTimeline/View Controllers/AudioRecorderController.swift b/LambdaTimeline/View Controllers/AudioRecorderController.swift new file mode 100644 index 00000000..85411490 --- /dev/null +++ b/LambdaTimeline/View Controllers/AudioRecorderController.swift @@ -0,0 +1,298 @@ +// +// 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) + } + + 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 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) + } + + cancelTimer() + } + + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + if let error = error { + print("Audio Recording Error: \(error)") + } + } +} diff --git a/LambdaTimeline/View Controllers/CameraViewController.swift b/LambdaTimeline/View Controllers/CameraViewController.swift new file mode 100644 index 00000000..fb7433f2 --- /dev/null +++ b/LambdaTimeline/View Controllers/CameraViewController.swift @@ -0,0 +1,245 @@ +// +// CameraViewController.swift +// LambdaTimeline +// +// Created by Kenneth Jones on 11/11/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation +import MapKit + +class CameraViewController: UIViewController { + + var postController: PostController! + var postTitle = "No Title" + + let locationManager = CLLocationManager() + var currentLocation: CLLocationCoordinate2D? + var getLocation = false + + lazy private var captureSession = AVCaptureSession() + lazy private var fileOutput = AVCaptureMovieFileOutput() + + @IBOutlet var recordButton: UIButton! + @IBOutlet var cameraView: CameraPreviewView! + + override func viewDidLoad() { + super.viewDidLoad() + + cameraView.videoPlayerLayer.videoGravity = .resizeAspectFill + setupCamera() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + captureSession.startRunning() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + captureSession.stopRunning() + } + + private func setupCamera() { + let camera = bestCamera + let microphone = bestMicrophone + + captureSession.beginConfiguration() + + guard let cameraInput = try? AVCaptureDeviceInput(device: camera) else { + preconditionFailure("Can't create an input from the camera.") + } + + guard let microphoneInput = try? AVCaptureDeviceInput(device: microphone) else { + preconditionFailure("Can't create an input from the microphone.") + } + + guard captureSession.canAddInput(cameraInput) else { + preconditionFailure("This session can't handle this type of input: \(cameraInput)") + } + + captureSession.addInput(cameraInput) + + guard captureSession.canAddInput(microphoneInput) else { + preconditionFailure("This session can't handle this type of input: \(microphoneInput)") + } + + captureSession.addInput(microphoneInput) + + if captureSession.canSetSessionPreset(.hd1920x1080) { + captureSession.sessionPreset = .hd1920x1080 + } + + guard captureSession.canAddOutput(fileOutput) else { + preconditionFailure("This session can't handle this type of output: \(fileOutput)") + } + + captureSession.addOutput(fileOutput) + + captureSession.commitConfiguration() + + cameraView.session = captureSession + } + + private var bestCamera: AVCaptureDevice { + if let device = AVCaptureDevice.default(.builtInUltraWideCamera, for: .video, position: .back) { + return device + } else if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) { + return device + } + + preconditionFailure("No cameras on device match the specs that we need.") + } + + private var bestMicrophone: AVCaptureDevice { + if let device = AVCaptureDevice.default(for: .audio) { + return device + } + + preconditionFailure("No microphones on device match the specs that we need.") + } + + @IBAction func recordButtonPressed(_ sender: Any) { + if fileOutput.isRecording { + fileOutput.stopRecording() + } else { + fileOutput.startRecording(to: newRecordingURL(), recordingDelegate: self) + } + } + + private func newRecordingURL() -> URL { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + + let name = formatter.string(from: Date()) + let fileURL = documentsDirectory.appendingPathComponent(name).appendingPathExtension("mov") + return fileURL + } + + func updateViews() { + recordButton.isSelected = fileOutput.isRecording + } + + func addTitle() { + let alert = UIAlertController(title: "Add a title", message: "Write your title below:", preferredStyle: .alert) + + var titleTextField: UITextField? + + alert.addTextField { (textField) in + textField.placeholder = "Post Title:" + titleTextField = textField + } + + let addTitleAction = UIAlertAction(title: "Add Title", style: .default) { (_) in + + guard let titleText = titleTextField?.text, + !titleText.isEmpty else { return } + + self.postTitle = titleText + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + + alert.addAction(addTitleAction) + alert.addAction(cancelAction) + + present(alert, animated: true, completion: nil) + } + + func addLocation() { + let alert = UIAlertController(title: "Add a location", message: "Would you like to include your location in the post?", preferredStyle: .alert) + + let yesAction = UIAlertAction(title: "Yes", style: .default) { (_) in + self.locationManager.requestWhenInUseAuthorization() + self.getLocation = true + + if CLLocationManager.locationServicesEnabled() { + self.locationManager.delegate = self + self.locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + self.locationManager.startUpdatingLocation() + } + } + + let noAction = UIAlertAction(title: "No", style: .cancel) { (_) in + self.getLocation = false + self.currentLocation = nil + } + + alert.addAction(yesAction) + alert.addAction(noAction) + + present(alert, animated: true, completion: nil) + } +} + + +extension CameraViewController: AVCaptureFileOutputRecordingDelegate { + + func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) { + updateViews() + } + + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + if let error = error { + print("Error saving video: \(error)") + } + + print("Video URL: \(outputFileURL)") + + addTitle() + addLocation() + imageFromVideo(url: outputFileURL, at: 0) { (image) in + self.postController.createVideoPost(with: self.postTitle, image: image!, video: outputFileURL, ratio: image?.vidRatio, geotag: self.currentLocation) + + if self.currentLocation != nil { + self.postController.createMapPost(with: self.postTitle, location: self.currentLocation!) + } + } + updateViews() + } + +} + + +extension CameraViewController: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let locValue: CLLocationCoordinate2D = manager.location?.coordinate else { return } + + if getLocation { + currentLocation = locValue + } + } +} + + +extension CameraViewController { + // Function sourced from StackOverflow to capture an image from the video for use in the PostsCollectionViewController + public func imageFromVideo(url: URL, at time: TimeInterval, completion: @escaping (UIImage?) -> Void) { + DispatchQueue.global(qos: .background).async { + let asset = AVURLAsset(url: url) + + let assetIG = AVAssetImageGenerator(asset: asset) + assetIG.appliesPreferredTrackTransform = true + assetIG.apertureMode = AVAssetImageGenerator.ApertureMode.encodedPixels + + let cmTime = CMTime(seconds: time, preferredTimescale: 60) + let thumbnailImageRef: CGImage + do { + thumbnailImageRef = try assetIG.copyCGImage(at: cmTime, actualTime: nil) + } catch let error { + print("Error: \(error)") + return completion(nil) + } + + DispatchQueue.main.async { + completion(UIImage(cgImage: thumbnailImageRef)) + } + } + } +} diff --git a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift index 31b43fa3..ef8f7822 100644 --- a/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostDetailTableViewController.swift @@ -7,31 +7,123 @@ // import UIKit +import AVFoundation +import AVKit 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? + + lazy private var player = AVPlayer() + private var playerView: VideoPlayerView! + + 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() } - func updateViews() { + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - guard let imageData = imageData, - let image = UIImage(data: imageData) else { return } + tableView.reloadData() + } + + func updateViews() { + if case MediaType.image(let image) = post.mediaType { + imageView.image = image + } else if case MediaType.video(let url) = post.mediaType { + imageView.image = post.frameCap + playMovie(url: url) + } title = post?.title - imageView.image = image - titleLabel.text = post.title - authorLabel.text = post.author.displayName + authorLabel.text = post.author + } + + func playMovie(url: URL) { + player.replaceCurrentItem(with: AVPlayerItem(url: url)) + + if playerView == nil { + playerView = VideoPlayerView() + playerView.player = player + + var topRect = view.bounds + topRect.size.width /= 4 + topRect.size.height /= 4 + topRect.origin.y = view.layoutMargins.top + topRect.origin.x = view.bounds.size.width - topRect.size.width + + playerView.frame = topRect + view.addSubview(playerView) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(playRecording(_:))) + playerView.addGestureRecognizer(tapGesture) + } + + player.play() + } + + @IBAction func playRecording(_ sender: UITapGestureRecognizer) { + guard sender.state == .ended else { return } + + let playerVC = AVPlayerViewController() + playerVC.player = player + + self.present(playerVC, animated: true, completion: nil) } // 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? @@ -69,20 +161,73 @@ class ImagePostDetailTableViewController: UITableViewController { let comment = post?.comments[indexPath.row + 1] - cell.textLabel?.text = comment?.text - cell.detailTextLabel?.text = comment?.author.displayName + 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 } - var post: Post! - var postController: PostController! - var imageData: Data? + 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() + } + } - @IBOutlet weak var imageView: UIImageView! - @IBOutlet weak var titleLabel: UILabel! - @IBOutlet weak var authorLabel: UILabel! - @IBOutlet weak var imageViewAspectRatioConstraint: NSLayoutConstraint! + 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 index c30bca9a..e69fed1f 100644 --- a/LambdaTimeline/View Controllers/ImagePostViewController.swift +++ b/LambdaTimeline/View Controllers/ImagePostViewController.swift @@ -7,33 +7,96 @@ // import UIKit +import CoreImage +import CoreImage.CIFilterBuiltins import Photos class ImagePostViewController: ShiftableViewController { - override func viewDidLoad() { - super.viewDidLoad() - - setImageViewHeight(with: 1.0) - - updateViews() - } + @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! - func updateViews() { - - guard let imageData = imageData, - let image = UIImage(data: imageData) else { - title = "New Post" + @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! + + @IBOutlet weak var addLocationSwitch: UISwitch! + + 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 getLocation = false + var currentLocation: CLLocationCoordinate2D? + let locationManager = CLLocationManager() + + 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() - title = post?.title - - setImageViewHeight(with: image.ratio) + setImageViewHeight(with: 1.0) - imageView.image = image + addLocationSwitch.setOn(false, animated: true) - chooseImageButton.setTitle("", for: []) + 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() { @@ -43,41 +106,73 @@ class ImagePostViewController: ShiftableViewController { return } - let imagePicker = UIImagePickerController() + 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 - imagePicker.delegate = self + vibranceFilter.inputImage = exposureFilter.outputImage + vibranceFilter.amount = vibranceSlider.value - imagePicker.sourceType = .photoLibrary - - present(imagePicker, animated: true, completion: nil) + 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 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 + 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, geotag: currentLocation) - postController.createPost(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) - } + if currentLocation != nil { + postController.createMapPost(with: title, location: currentLocation!) } + + navigationController?.popViewController(animated: true) } @IBAction func chooseImage(_ sender: Any) { - let authorizationStatus = PHPhotoLibrary.authorizationStatus() switch authorizationStatus { @@ -100,11 +195,28 @@ class ImagePostViewController: ShiftableViewController { 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 toggleLocation(_ sender: Any) { + if addLocationSwitch.isOn { + locationManager.requestWhenInUseAuthorization() + getLocation = true + + if CLLocationManager.locationServicesEnabled() { + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.startUpdatingLocation() + } + } else { + getLocation = false + currentLocation = nil + } + } + func setImageViewHeight(with aspectRatio: CGFloat) { imageHeightConstraint.constant = imageView.frame.size.width * aspectRatio @@ -112,15 +224,40 @@ class ImagePostViewController: ShiftableViewController { view.layoutSubviews() } - var postController: PostController! - var post: Post? - var imageData: Data? + @IBAction func clearFilters(_ sender: Any) { + selectedFilter = nil + exposureSlider.value = 0.5 + vibranceSlider.value = 0 + posterizeSlider.value = 50 + updateImage() + } - @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! + @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 { @@ -136,9 +273,39 @@ extension ImagePostViewController: UIImagePickerControllerDelegate, UINavigation 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) } } + + +extension ImagePostViewController: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let locValue: CLLocationCoordinate2D = manager.location?.coordinate else { return } + + if getLocation { + currentLocation = locValue + } + } +} diff --git a/LambdaTimeline/View Controllers/PostMapViewController.swift b/LambdaTimeline/View Controllers/PostMapViewController.swift new file mode 100644 index 00000000..960392da --- /dev/null +++ b/LambdaTimeline/View Controllers/PostMapViewController.swift @@ -0,0 +1,50 @@ +// +// PostMapViewController.swift +// LambdaTimeline +// +// Created by Kenneth Jones on 11/15/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import MapKit + +enum ReuseIdentifier { + static let postAnnotation = "PostAnnotationView" +} + +class PostMapViewController: UIViewController, MKMapViewDelegate, CLLocationManagerDelegate { + + @IBOutlet weak var mapView: MKMapView! + + private var userTrackingButton: MKUserTrackingButton! + + private let locationManager = CLLocationManager() + + var postController: PostController! + + override func viewDidLoad() { + super.viewDidLoad() + + locationManager.requestWhenInUseAuthorization() + + userTrackingButton = MKUserTrackingButton(mapView: mapView) + userTrackingButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(userTrackingButton) + + NSLayoutConstraint.activate([ + userTrackingButton.leadingAnchor.constraint(equalTo: mapView.leadingAnchor, constant: 20), + mapView.bottomAnchor.constraint(equalTo: userTrackingButton.bottomAnchor, constant: 20) + ]) + + if CLLocationManager.locationServicesEnabled() { + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.startUpdatingLocation() + } + + mapView.register(MKMarkerAnnotationView.self, forAnnotationViewWithReuseIdentifier: ReuseIdentifier.postAnnotation) + + mapView.addAnnotations(postController.mapPosts) + } +} diff --git a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift index 3843e060..2b9ce2b0 100644 --- a/LambdaTimeline/View Controllers/PostsCollectionViewController.swift +++ b/LambdaTimeline/View Controllers/PostsCollectionViewController.swift @@ -7,19 +7,16 @@ // import UIKit -import FirebaseAuth -import FirebaseUI +import AVFoundation class PostsCollectionViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout { - override func viewDidLoad() { - super.viewDidLoad() + var postController: PostController! + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) - postController.observePosts { (_) in - DispatchQueue.main.async { - self.collectionView.reloadData() - } - } + collectionView.reloadData() } @IBAction func addPost(_ sender: Any) { @@ -30,34 +27,65 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie self.performSegue(withIdentifier: "AddImagePost", sender: nil) } + let videoPostAction = UIAlertAction(title: "Video", style: .default) { (_) in + self.requestPermissionAndShowCamera() + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) alert.addAction(imagePostAction) + alert.addAction(videoPostAction) alert.addAction(cancelAction) self.present(alert, animated: true, completion: nil) } + private func requestPermissionAndShowCamera() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .notDetermined: + requestVideoPermission() + case .restricted: + preconditionFailure("Video is disabled, please review device restrictions.") + case .denied: + preconditionFailure("Hey you denied us, but you can't use the app without giving permission, so go in Settings and make this right.") + case .authorized: + showCamera() + @unknown default: + preconditionFailure("A new status code was added that we need to handle.") + } + } + + private func requestVideoPermission() { + AVCaptureDevice.requestAccess(for: .video) { (isGranted) in + guard isGranted else { + preconditionFailure("UI: Tell the user to enable permissions for Video/Camera") + } + + DispatchQueue.main.async { + self.showCamera() + } + } + } + + private func showCamera() { + performSegue(withIdentifier: "AddVideoPost", sender: self) + } + // MARK: UICollectionViewDataSource override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return postController.posts.count + 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 - - loadImage(for: cell, forItemAt: indexPath) - - return cell - } + 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 { @@ -66,14 +94,9 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie let post = postController.posts[indexPath.row] - switch post.mediaType { - - case .image: - - guard let ratio = post.ratio else { return size } - - size.height = size.width * ratio - } + guard let ratio = post.ratio else { return size } + + size.height = size.width * ratio return size } @@ -88,59 +111,7 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie } } - 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 @@ -148,22 +119,19 @@ class PostsCollectionViewController: UICollectionViewController, UICollectionVie 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, - let postID = postController.posts[indexPath.row].id else { return } + guard let indexPath = collectionView.indexPathsForSelectedItems?.first else { return } destinationVC?.postController = postController destinationVC?.post = postController.posts[indexPath.row] - destinationVC?.imageData = cache.value(for: postID) + } else if segue.identifier == "AddVideoPost" { + let destinationVC = segue.destination as? CameraViewController + destinationVC?.postController = postController + } else if segue.identifier == "MapViewSegue" { + let destinationVC = segue.destination as? PostMapViewController + destinationVC?.postController = postController } } - - 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 2b1e2d01..c81bb748 100644 --- a/LambdaTimeline/View Controllers/SignInViewController.swift +++ b/LambdaTimeline/View Controllers/SignInViewController.swift @@ -7,68 +7,38 @@ // import UIKit -import Firebase -import GoogleSignIn - -class SignInViewController: UIViewController, GIDSignInDelegate, GIDSignInUIDelegate { +class SignInViewController: UIViewController { + + @IBOutlet weak var nameTextField: UITextField! + + let postController = PostController() override func viewDidLoad() { super.viewDidLoad() - - let signIn = GIDSignIn.sharedInstance() - - signIn?.delegate = self - signIn?.uiDelegate = self - signIn?.signInSilently() - - setUpSignInButton() } - func sign(_ signIn: GIDSignIn!, didSignInFor user: GIDGoogleUser!, withError error: Error!) { - - if let error = error { - NSLog("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().signInAndRetrieveData(with: credential) { (authResult, error) in - if let error = error { - NSLog("Error signing in with Google: \(error)") - return - } - - DispatchQueue.main.async { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let postsNavigationController = storyboard.instantiateViewController(withIdentifier: "PostsNavigationController") - self.present(postsNavigationController, animated: true, completion: nil) - } - } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + segueIfUsernameExists() } - func sign(_ signIn: GIDSignIn!, didDisconnectWith user: GIDGoogleUser!, withError error: Error!) { - print("User disconnected") + @IBAction func getStarted(_ sender: Any) { + UserDefaults.standard.set(nameTextField.text, forKey: "username") + segueIfUsernameExists() } - 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]) + 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/CameraPreviewView.swift b/LambdaTimeline/Views/CameraPreviewView.swift new file mode 100644 index 00000000..3b3d4310 --- /dev/null +++ b/LambdaTimeline/Views/CameraPreviewView.swift @@ -0,0 +1,26 @@ +// +// CameraPreviewView.swift +// LambdaTimeline +// +// Created by Kenneth Jones on 11/11/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class CameraPreviewView: UIView { + + override class var layerClass: AnyClass { + return AVCaptureVideoPreviewLayer.self + } + + var videoPlayerLayer: AVCaptureVideoPreviewLayer { + return layer as! AVCaptureVideoPreviewLayer + } + + var session: AVCaptureSession? { + get { return videoPlayerLayer.session } + set { videoPlayerLayer.session = newValue } + } +} diff --git a/LambdaTimeline/Views/ImagePostCollectionViewCell.swift b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift index 3841cf65..1ac3c3b4 100644 --- a/LambdaTimeline/Views/ImagePostCollectionViewCell.swift +++ b/LambdaTimeline/Views/ImagePostCollectionViewCell.swift @@ -10,10 +10,22 @@ 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() @@ -24,31 +36,24 @@ class ImagePostCollectionViewCell: UICollectionViewCell { func updateViews() { guard let post = post else { return } - + titleLabel.text = post.title - authorLabel.text = post.author.displayName + authorLabel.text = post.author + + if case MediaType.image(let image) = post.mediaType { + imageView.image = image + } else { + imageView.image = post.frameCap + } } - + 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/LambdaTimeline/Views/VideoPlayerView.swift b/LambdaTimeline/Views/VideoPlayerView.swift new file mode 100644 index 00000000..967c546f --- /dev/null +++ b/LambdaTimeline/Views/VideoPlayerView.swift @@ -0,0 +1,26 @@ +// +// VideoPlayerView.swift +// LambdaTimeline +// +// Created by Kenneth Jones on 11/11/20. +// Copyright © 2020 Lambda School. All rights reserved. +// + +import UIKit +import AVFoundation + +class VideoPlayerView: UIView { + + override class var layerClass: AnyClass { + return AVPlayerLayer.self + } + + var videoPlayerLayer: AVPlayerLayer { + return layer as! AVPlayerLayer + } + + var player: AVPlayer? { + get { return videoPlayerLayer.player } + set { videoPlayerLayer.player = newValue } + } +} diff --git a/Podfile b/Podfile deleted file mode 100644 index 67626d26..00000000 --- a/Podfile +++ /dev/null @@ -1,15 +0,0 @@ -# Uncomment the next line to define a global platform for your project -platform :ios, '12.0' - -target 'LambdaTimeline' do - # Comment the next line if you're not using Swift and don't want to use dynamic frameworks - use_frameworks! - - # Pods for LambdaTimeline -pod 'Firebase/Core' -pod 'Firebase/Database' -pod 'Firebase/Storage' -pod 'Firebase/Auth' -pod 'FirebaseUI/Google' - -end diff --git a/Podfile.lock b/Podfile.lock deleted file mode 100644 index 34235820..00000000 --- a/Podfile.lock +++ /dev/null @@ -1,145 +0,0 @@ -PODS: - - Firebase/Auth (5.8.0): - - Firebase/CoreOnly - - FirebaseAuth (= 5.0.4) - - Firebase/Core (5.8.0): - - Firebase/CoreOnly - - FirebaseAnalytics (= 5.1.2) - - Firebase/CoreOnly (5.8.0): - - FirebaseCore (= 5.1.3) - - Firebase/Database (5.8.0): - - Firebase/CoreOnly - - FirebaseDatabase (= 5.0.3) - - Firebase/Storage (5.8.0): - - Firebase/CoreOnly - - FirebaseStorage (= 3.0.2) - - FirebaseAnalytics (5.1.2): - - FirebaseCore (~> 5.1) - - FirebaseInstanceID (~> 3.2) - - GoogleAppMeasurement (~> 5.1) - - GoogleUtilities/AppDelegateSwizzler (~> 5.2.0) - - GoogleUtilities/MethodSwizzler (~> 5.2.0) - - GoogleUtilities/Network (~> 5.2) - - "GoogleUtilities/NSData+zlib (~> 5.2)" - - nanopb (~> 0.3) - - FirebaseAuth (5.0.4): - - FirebaseAuthInterop (~> 1.0) - - FirebaseCore (~> 5.0) - - GoogleUtilities/Environment (~> 5.2) - - GTMSessionFetcher/Core (~> 1.1) - - FirebaseAuthInterop (1.0.0) - - FirebaseCore (5.1.3): - - GoogleUtilities/Logger (~> 5.2) - - FirebaseDatabase (5.0.3): - - FirebaseCore (~> 5.0) - - leveldb-library (~> 1.18) - - FirebaseInstanceID (3.2.1): - - FirebaseCore (~> 5.1) - - GoogleUtilities/Environment (~> 5.2) - - FirebaseStorage (3.0.2): - - FirebaseAuthInterop (~> 1.0) - - FirebaseCore (~> 5.0) - - GTMSessionFetcher/Core (~> 1.1) - - FirebaseUI/Auth (5.2.2): - - Firebase/Auth (~> 5.0) - - FirebaseUI/Google (5.2.2): - - FirebaseUI/Auth - - GoogleSignIn (~> 4.0) - - GoogleAppMeasurement (5.1.2): - - GoogleUtilities/AppDelegateSwizzler (~> 5.2.0) - - GoogleUtilities/MethodSwizzler (~> 5.2.0) - - GoogleUtilities/Network (~> 5.2) - - "GoogleUtilities/NSData+zlib (~> 5.2)" - - nanopb (~> 0.3) - - GoogleSignIn (4.2.0): - - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" - - "GoogleToolboxForMac/NSString+URLArguments (~> 2.1)" - - GTMOAuth2 (~> 1.0) - - GTMSessionFetcher/Core (~> 1.1) - - GoogleToolboxForMac/DebugUtils (2.1.4): - - GoogleToolboxForMac/Defines (= 2.1.4) - - GoogleToolboxForMac/Defines (2.1.4) - - "GoogleToolboxForMac/NSDictionary+URLArguments (2.1.4)": - - GoogleToolboxForMac/DebugUtils (= 2.1.4) - - GoogleToolboxForMac/Defines (= 2.1.4) - - "GoogleToolboxForMac/NSString+URLArguments (= 2.1.4)" - - "GoogleToolboxForMac/NSString+URLArguments (2.1.4)" - - GoogleUtilities/AppDelegateSwizzler (5.2.3): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - - GoogleUtilities/Environment (5.2.3) - - GoogleUtilities/Logger (5.2.3): - - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (5.2.3): - - GoogleUtilities/Logger - - GoogleUtilities/Network (5.2.3): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (5.2.3)" - - GoogleUtilities/Reachability (5.2.3): - - GoogleUtilities/Logger - - GTMOAuth2 (1.1.6): - - GTMSessionFetcher (~> 1.1) - - GTMSessionFetcher (1.2.0): - - GTMSessionFetcher/Full (= 1.2.0) - - GTMSessionFetcher/Core (1.2.0) - - GTMSessionFetcher/Full (1.2.0): - - GTMSessionFetcher/Core (= 1.2.0) - - leveldb-library (1.20) - - nanopb (0.3.8): - - nanopb/decode (= 0.3.8) - - nanopb/encode (= 0.3.8) - - nanopb/decode (0.3.8) - - nanopb/encode (0.3.8) - -DEPENDENCIES: - - Firebase/Auth - - Firebase/Core - - Firebase/Database - - Firebase/Storage - - FirebaseUI/Google - -SPEC REPOS: - https://github.com/cocoapods/specs.git: - - Firebase - - FirebaseAnalytics - - FirebaseAuth - - FirebaseAuthInterop - - FirebaseCore - - FirebaseDatabase - - FirebaseInstanceID - - FirebaseStorage - - FirebaseUI - - GoogleAppMeasurement - - GoogleSignIn - - GoogleToolboxForMac - - GoogleUtilities - - GTMOAuth2 - - GTMSessionFetcher - - leveldb-library - - nanopb - -SPEC CHECKSUMS: - Firebase: 25812f43e7a53b11ae2f0a5f4c6d12faeb1f7cd7 - FirebaseAnalytics: df15839e9c6ca6bd14d2e8ab6b0c672e6c49097e - FirebaseAuth: 504b198ceb3472dca5c65bb95544ea44cfc9439e - FirebaseAuthInterop: 0ffa57668be100582bb7643d4fcb7615496c41fc - FirebaseCore: 27bd80e5bfaaf9552a1f5cacb4c7e8bb925bab22 - FirebaseDatabase: e2bcbc106adc4b11a2da3ec2eb63c0c4a44f2f54 - FirebaseInstanceID: ea5af6920d0a4a29b40459d055bebe4a6c1333c4 - FirebaseStorage: fd82e5e5c474897e19972b34b22ac0f589dce04e - FirebaseUI: 09519bf436a055cd696bf68687d624423150e4c0 - GoogleAppMeasurement: fc4a4c3fe0144db9313cbf443ffe62e6b1d6268c - GoogleSignIn: 591e46382014e591269f862ba6e7bc0fbd793532 - GoogleToolboxForMac: 91c824d21e85b31c2aae9bb011c5027c9b4e738f - GoogleUtilities: 6f681e27050c5e130325e89fa0316dfca826f954 - GTMOAuth2: c77fe325e4acd453837e72d91e3b5f13116857b2 - GTMSessionFetcher: 0c4baf0a73acd0041bf9f71ea018deedab5ea84e - leveldb-library: 08cba283675b7ed2d99629a4bc5fd052cd2bb6a5 - nanopb: 5601e6bca2dbf1ed831b519092ec110f66982ca3 - -PODFILE CHECKSUM: 05eb2a7c149e4b5105c37446a97414485c09ec52 - -COCOAPODS: 1.5.3