diff --git a/experiences/memories.xcodeproj/project.pbxproj b/experiences/memories.xcodeproj/project.pbxproj new file mode 100644 index 00000000..c9546191 --- /dev/null +++ b/experiences/memories.xcodeproj/project.pbxproj @@ -0,0 +1,437 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 0B02C8DC250B142700634337 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8DB250B142700634337 /* AppDelegate.swift */; }; + 0B02C8DE250B142700634337 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8DD250B142700634337 /* SceneDelegate.swift */; }; + 0B02C8E3250B142700634337 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0B02C8E1250B142700634337 /* Main.storyboard */; }; + 0B02C8E5250B142900634337 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0B02C8E4250B142900634337 /* Assets.xcassets */; }; + 0B02C8E8250B142900634337 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 0B02C8E6250B142900634337 /* LaunchScreen.storyboard */; }; + 0B02C8F2250B1AB100634337 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8F1250B1AB100634337 /* LoginViewController.swift */; }; + 0B02C8F6250B1E3600634337 /* login.mov in Resources */ = {isa = PBXBuildFile; fileRef = 0B02C8F5250B1E3600634337 /* login.mov */; }; + 0B02C8F8250B275A00634337 /* MemoriesCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8F7250B275A00634337 /* MemoriesCollectionViewController.swift */; }; + 0B02C8FA250B2CDA00634337 /* PhotoCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8F9250B2CDA00634337 /* PhotoCollectionViewCell.swift */; }; + 0B02C8FC250B2CF000634337 /* AudioCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8FB250B2CF000634337 /* AudioCollectionViewCell.swift */; }; + 0B02C8FE250B2CFF00634337 /* TextCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8FD250B2CFF00634337 /* TextCollectionViewCell.swift */; }; + 0B02C900250B2F5600634337 /* PostController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C8FF250B2F5600634337 /* PostController.swift */; }; + 0B02C902250B351E00634337 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C901250B351E00634337 /* Post.swift */; }; + 0B02C904250B381700634337 /* AudioVisualizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C903250B381700634337 /* AudioVisualizer.swift */; }; + 0B02C906250B39BB00634337 /* TextEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C905250B39BB00634337 /* TextEntryViewController.swift */; }; + 0B02C908250B39D700634337 /* AudioEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C907250B39D700634337 /* AudioEntryViewController.swift */; }; + 0B02C90A250B39E100634337 /* PhotoEntryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B02C909250B39E100634337 /* PhotoEntryViewController.swift */; }; + 0BB29E40250C2AE400D4B974 /* MapViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB29E3F250C2AE400D4B974 /* MapViewController.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0B02C8D8250B142700634337 /* memories.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = memories.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 0B02C8DB250B142700634337 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 0B02C8DD250B142700634337 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 0B02C8E2250B142700634337 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 0B02C8E4250B142900634337 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 0B02C8E7250B142900634337 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 0B02C8E9250B142900634337 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0B02C8F1250B1AB100634337 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; + 0B02C8F5250B1E3600634337 /* login.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = login.mov; sourceTree = ""; }; + 0B02C8F7250B275A00634337 /* MemoriesCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoriesCollectionViewController.swift; sourceTree = ""; }; + 0B02C8F9250B2CDA00634337 /* PhotoCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCollectionViewCell.swift; sourceTree = ""; }; + 0B02C8FB250B2CF000634337 /* AudioCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioCollectionViewCell.swift; sourceTree = ""; }; + 0B02C8FD250B2CFF00634337 /* TextCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextCollectionViewCell.swift; sourceTree = ""; }; + 0B02C8FF250B2F5600634337 /* PostController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostController.swift; sourceTree = ""; }; + 0B02C901250B351E00634337 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; + 0B02C903250B381700634337 /* AudioVisualizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioVisualizer.swift; sourceTree = ""; }; + 0B02C905250B39BB00634337 /* TextEntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextEntryViewController.swift; sourceTree = ""; }; + 0B02C907250B39D700634337 /* AudioEntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEntryViewController.swift; sourceTree = ""; }; + 0B02C909250B39E100634337 /* PhotoEntryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoEntryViewController.swift; sourceTree = ""; }; + 0BB29E3F250C2AE400D4B974 /* MapViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapViewController.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0B02C8D5250B142700634337 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0B02C8CF250B142700634337 = { + isa = PBXGroup; + children = ( + 0B02C8DA250B142700634337 /* memories */, + 0B02C8D9250B142700634337 /* Products */, + ); + sourceTree = ""; + }; + 0B02C8D9250B142700634337 /* Products */ = { + isa = PBXGroup; + children = ( + 0B02C8D8250B142700634337 /* memories.app */, + ); + name = Products; + sourceTree = ""; + }; + 0B02C8DA250B142700634337 /* memories */ = { + isa = PBXGroup; + children = ( + 0BF89252250C799000B60DEA /* ModelController */, + 0BF89251250C798300B60DEA /* Model */, + 0BF89250250C796F00B60DEA /* ViewControllers */, + 0BF8924F250C795900B60DEA /* Views */, + 0BF8924E250C794100B60DEA /* Storyboards */, + 0BF8924D250C791B00B60DEA /* Resources */, + 0B02C8E9250B142900634337 /* Info.plist */, + 0B02C8F5250B1E3600634337 /* login.mov */, + ); + path = memories; + sourceTree = ""; + }; + 0BF8924D250C791B00B60DEA /* Resources */ = { + isa = PBXGroup; + children = ( + 0B02C8DB250B142700634337 /* AppDelegate.swift */, + 0B02C8DD250B142700634337 /* SceneDelegate.swift */, + 0B02C8E4250B142900634337 /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; + 0BF8924E250C794100B60DEA /* Storyboards */ = { + isa = PBXGroup; + children = ( + 0B02C8E1250B142700634337 /* Main.storyboard */, + 0B02C8E6250B142900634337 /* LaunchScreen.storyboard */, + ); + path = Storyboards; + sourceTree = ""; + }; + 0BF8924F250C795900B60DEA /* Views */ = { + isa = PBXGroup; + children = ( + 0B02C903250B381700634337 /* AudioVisualizer.swift */, + 0B02C8F9250B2CDA00634337 /* PhotoCollectionViewCell.swift */, + 0B02C8FB250B2CF000634337 /* AudioCollectionViewCell.swift */, + 0B02C8FD250B2CFF00634337 /* TextCollectionViewCell.swift */, + ); + path = Views; + sourceTree = ""; + }; + 0BF89250250C796F00B60DEA /* ViewControllers */ = { + isa = PBXGroup; + children = ( + 0B02C8F1250B1AB100634337 /* LoginViewController.swift */, + 0B02C8F7250B275A00634337 /* MemoriesCollectionViewController.swift */, + 0B02C905250B39BB00634337 /* TextEntryViewController.swift */, + 0B02C907250B39D700634337 /* AudioEntryViewController.swift */, + 0B02C909250B39E100634337 /* PhotoEntryViewController.swift */, + 0BB29E3F250C2AE400D4B974 /* MapViewController.swift */, + ); + path = ViewControllers; + sourceTree = ""; + }; + 0BF89251250C798300B60DEA /* Model */ = { + isa = PBXGroup; + children = ( + 0B02C901250B351E00634337 /* Post.swift */, + ); + path = Model; + sourceTree = ""; + }; + 0BF89252250C799000B60DEA /* ModelController */ = { + isa = PBXGroup; + children = ( + 0B02C8FF250B2F5600634337 /* PostController.swift */, + ); + path = ModelController; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0B02C8D7250B142700634337 /* memories */ = { + isa = PBXNativeTarget; + buildConfigurationList = 0B02C8EC250B142900634337 /* Build configuration list for PBXNativeTarget "memories" */; + buildPhases = ( + 0B02C8D4250B142700634337 /* Sources */, + 0B02C8D5250B142700634337 /* Frameworks */, + 0B02C8D6250B142700634337 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = memories; + productName = memories; + productReference = 0B02C8D8250B142700634337 /* memories.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 0B02C8D0250B142700634337 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1170; + LastUpgradeCheck = 1170; + ORGANIZATIONNAME = "Clayton Watkins"; + TargetAttributes = { + 0B02C8D7250B142700634337 = { + CreatedOnToolsVersion = 11.7; + }; + }; + }; + buildConfigurationList = 0B02C8D3250B142700634337 /* Build configuration list for PBXProject "memories" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 0B02C8CF250B142700634337; + productRefGroup = 0B02C8D9250B142700634337 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 0B02C8D7250B142700634337 /* memories */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 0B02C8D6250B142700634337 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0B02C8E8250B142900634337 /* LaunchScreen.storyboard in Resources */, + 0B02C8E5250B142900634337 /* Assets.xcassets in Resources */, + 0B02C8F6250B1E3600634337 /* login.mov in Resources */, + 0B02C8E3250B142700634337 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 0B02C8D4250B142700634337 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0B02C8F2250B1AB100634337 /* LoginViewController.swift in Sources */, + 0B02C900250B2F5600634337 /* PostController.swift in Sources */, + 0B02C906250B39BB00634337 /* TextEntryViewController.swift in Sources */, + 0B02C8FA250B2CDA00634337 /* PhotoCollectionViewCell.swift in Sources */, + 0B02C908250B39D700634337 /* AudioEntryViewController.swift in Sources */, + 0B02C8F8250B275A00634337 /* MemoriesCollectionViewController.swift in Sources */, + 0B02C904250B381700634337 /* AudioVisualizer.swift in Sources */, + 0B02C8FE250B2CFF00634337 /* TextCollectionViewCell.swift in Sources */, + 0B02C8DC250B142700634337 /* AppDelegate.swift in Sources */, + 0B02C902250B351E00634337 /* Post.swift in Sources */, + 0B02C8DE250B142700634337 /* SceneDelegate.swift in Sources */, + 0B02C8FC250B2CF000634337 /* AudioCollectionViewCell.swift in Sources */, + 0BB29E40250C2AE400D4B974 /* MapViewController.swift in Sources */, + 0B02C90A250B39E100634337 /* PhotoEntryViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 0B02C8E1250B142700634337 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0B02C8E2250B142700634337 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 0B02C8E6250B142900634337 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 0B02C8E7250B142900634337 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 0B02C8EA250B142900634337 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.7; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 0B02C8EB250B142900634337 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.7; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 0B02C8ED250B142900634337 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = memories/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.claytonwatkins.memories; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 0B02C8EE250B142900634337 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + INFOPLIST_FILE = memories/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.claytonwatkins.memories; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 0B02C8D3250B142700634337 /* Build configuration list for PBXProject "memories" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0B02C8EA250B142900634337 /* Debug */, + 0B02C8EB250B142900634337 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 0B02C8EC250B142900634337 /* Build configuration list for PBXNativeTarget "memories" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 0B02C8ED250B142900634337 /* Debug */, + 0B02C8EE250B142900634337 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 0B02C8D0250B142700634337 /* Project object */; +} diff --git a/experiences/memories/Info.plist b/experiences/memories/Info.plist new file mode 100644 index 00000000..19f78846 --- /dev/null +++ b/experiences/memories/Info.plist @@ -0,0 +1,70 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSLocationWhenInUseUsageDescription + $(PRODUCT_NAME) needs access to the GPS to save location. + NSMicrophoneUsageDescription + $(PRODUCT_NAME) needs access to the microphone to record audio. + NSPhotoLibraryUsageDescription + $(PRODUCT_NAME) needs access to the photo library to choose photos. + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/experiences/memories/Model/Post.swift b/experiences/memories/Model/Post.swift new file mode 100644 index 00000000..436f8333 --- /dev/null +++ b/experiences/memories/Model/Post.swift @@ -0,0 +1,29 @@ +// +// Post.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import MapKit + +class Post: NSObject { + + let image: UIImage? + let author: String + let title: String? + let audioURL: URL? + let location: CLLocationCoordinate2D + let entry: String? + + init(title: String?, author: String, location: CLLocationCoordinate2D, image: UIImage?, entry: String?, audioURL: URL?) { + self.title = title ?? nil + self.author = author + self.location = location + self.image = image ?? nil + self.entry = entry ?? nil + self.audioURL = audioURL ?? nil + } +} diff --git a/experiences/memories/ModelController/PostController.swift b/experiences/memories/ModelController/PostController.swift new file mode 100644 index 00000000..13afc82f --- /dev/null +++ b/experiences/memories/ModelController/PostController.swift @@ -0,0 +1,38 @@ +// +// PostController.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import MapKit + +class PostController { + + static let shared = PostController() + var posts: [Post] = [] + var currentUser: String? { + UserDefaults.standard.string(forKey: "username") + } + + func createImagePost(with title: String, image: UIImage, location: CLLocationCoordinate2D) { + guard let currentUser = currentUser else { return } + let post = Post(title: title, author: currentUser, location: location, image: image, entry: nil, audioURL: nil) + posts.append(post) + } + + func createTextPost(with title: String, entry: String, location: CLLocationCoordinate2D) { + guard let currentUser = currentUser else { return } + let post = Post(title: title, author: currentUser, location: location, image: nil, entry: entry, audioURL: nil) + posts.append(post) + } + + func createAudioPost(with title: String, audioURL: URL, location: CLLocationCoordinate2D) { + guard let currentUser = currentUser else { return } + let post = Post(title: title, author: currentUser, location: location, image: nil, entry: nil, audioURL: audioURL) + posts.append(post) + } + +} diff --git a/experiences/memories/Resources/AppDelegate.swift b/experiences/memories/Resources/AppDelegate.swift new file mode 100644 index 00000000..5a00b2a3 --- /dev/null +++ b/experiences/memories/Resources/AppDelegate.swift @@ -0,0 +1,37 @@ +// +// AppDelegate.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/experiences/memories/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/experiences/memories/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/experiences/memories/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/experiences/memories/Resources/Assets.xcassets/Contents.json b/experiences/memories/Resources/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/experiences/memories/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/experiences/memories/Resources/SceneDelegate.swift b/experiences/memories/Resources/SceneDelegate.swift new file mode 100644 index 00000000..47aa16b7 --- /dev/null +++ b/experiences/memories/Resources/SceneDelegate.swift @@ -0,0 +1,53 @@ +// +// SceneDelegate.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + +} + diff --git a/experiences/memories/Storyboards/Base.lproj/LaunchScreen.storyboard b/experiences/memories/Storyboards/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/experiences/memories/Storyboards/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experiences/memories/Storyboards/Base.lproj/Main.storyboard b/experiences/memories/Storyboards/Base.lproj/Main.storyboard new file mode 100644 index 00000000..54d255aa --- /dev/null +++ b/experiences/memories/Storyboards/Base.lproj/Main.storyboard @@ -0,0 +1,611 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/experiences/memories/ViewControllers/AudioEntryViewController.swift b/experiences/memories/ViewControllers/AudioEntryViewController.swift new file mode 100644 index 00000000..c1676fe7 --- /dev/null +++ b/experiences/memories/ViewControllers/AudioEntryViewController.swift @@ -0,0 +1,303 @@ +// +// AudioEntryViewController.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import AVFoundation +import MapKit + +class AudioEntryViewController: UIViewController { + + // MARK: - IBOutlets + @IBOutlet var playButton: UIButton! + @IBOutlet var recordButton: UIButton! + @IBOutlet var timeElapsedLabel: UILabel! + @IBOutlet var timeRemainingLabel: UILabel! + @IBOutlet var timeSlider: UISlider! + @IBOutlet var audioVisualizer: AudioVisualizer! + + //MARK: - Properties + var audioPlayer: AVAudioPlayer?{ + didSet { + guard let audioPlayer = audioPlayer else { return } + + audioPlayer.delegate = self + audioPlayer.isMeteringEnabled = true + updateViews() + } + } + + weak var timer: Timer? + var audioRecorder: AVAudioRecorder? + + let postController = PostController.shared + var post: Post! + var recordingURL: URL? + + private lazy var timeIntervalFormatter: DateComponentsFormatter = { + // NOTE: DateComponentFormatter is good for minutes/hours/seconds + // DateComponentsFormatter is not good for milliseconds, use DateFormatter instead) + + let formatting = DateComponentsFormatter() + formatting.unitsStyle = .positional // 00:00 mm:ss + formatting.zeroFormattingBehavior = .pad + formatting.allowedUnits = [.minute, .second] + return formatting + }() + + let locationManager = CLLocationManager.shared + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Use a font that won't jump around as values change + timeElapsedLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeElapsedLabel.font.pointSize, + weight: .regular) + timeRemainingLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeRemainingLabel.font.pointSize, + weight: .regular) + getCurrentLocation() + } + + func updateViews() { + playButton.isEnabled = !isRecording + recordButton.isEnabled = !isPlaying + timeSlider.isEnabled = !isRecording + playButton.isSelected = isPlaying + recordButton.isSelected = isRecording + if !isRecording { + let elapsedTime = audioPlayer?.currentTime ?? 0 + let duration = audioPlayer?.duration ?? 0 + let timeRemaining = duration.rounded() - elapsedTime + timeElapsedLabel.text = timeIntervalFormatter.string(from: elapsedTime) + timeSlider.minimumValue = 0 + timeSlider.maximumValue = Float(duration) + timeSlider.value = Float(elapsedTime) + timeRemainingLabel.text = "-" + timeIntervalFormatter.string(from: timeRemaining)! + } else { + let elapsedTime = audioRecorder?.currentTime ?? 0 + timeElapsedLabel.text = "--:--" + timeSlider.minimumValue = 0 + timeSlider.maximumValue = 1 + timeSlider.value = 0 + timeRemainingLabel.text = timeIntervalFormatter.string(from: elapsedTime) + } + } + + deinit { + timer?.invalidate() + } + + // MARK: - Timer + func startTimer() { + timer?.invalidate() + + timer = Timer.scheduledTimer(withTimeInterval: 0.030, repeats: true) { [weak self] (_) in + guard let self = self else { return } + + self.updateViews() + + if let audioRecorder = self.audioRecorder, + self.isRecording == true { + + audioRecorder.updateMeters() + self.audioVisualizer.addValue(decibelValue: audioRecorder.averagePower(forChannel: 0)) + + } + + if let audioPlayer = self.audioPlayer, + self.isPlaying == true { + + audioPlayer.updateMeters() + self.audioVisualizer.addValue(decibelValue: audioPlayer.averagePower(forChannel: 0)) + } + } + } + + func cancelTimer() { + timer?.invalidate() + timer = nil + } + + // MARK: - Playback + var isPlaying: Bool { + audioPlayer?.isPlaying ?? false + } + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playAndRecord, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) // can fail if on a phone call, for instance + } + + func play() { + do{ + try prepareAudioSession() + audioPlayer?.play() + updateViews() + startTimer() + } catch { + print("Can't play audio: \(error)") + } + } + + func pause() { + audioPlayer?.pause() + updateViews() + cancelTimer() + } + + + // MARK: - Recording + + var isRecording: Bool { + audioRecorder?.isRecording ?? false + } + + + func requestPermissionOrStartRecording() { + switch AVAudioSession.sharedInstance().recordPermission { + case .undetermined: + AVAudioSession.sharedInstance().requestRecordPermission { granted in + guard granted == true else { + print("We need microphone access") + return + } + + print("Recording permission has been granted!") + // NOTE: Invite the user to tap record again, since we just interrupted them, and they may not have been ready to record + } + case .denied: + print("Microphone access has been blocked.") + + let alertController = UIAlertController(title: "Microphone Access Denied", message: "Please allow this app to access your Microphone.", preferredStyle: .alert) + + alertController.addAction(UIAlertAction(title: "Open Settings", style: .default) { (_) in + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + }) + + alertController.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alertController, animated: true, completion: nil) + case .granted: + startRecording() + @unknown default: + break + } + } + + func newRecordingURL() -> URL { + let fm = FileManager.default + let documentsDir = try! fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + + let randomId = Int.random(in: 0...1_000_00) + + return documentsDir.appendingPathComponent("TestRecording" + "\(randomId)").appendingPathExtension("caf") + } + + func startRecording() { + do{ + try prepareAudioSession() + } catch { + print("Can't record audio: \(error)") + return + } + + recordingURL = newRecordingURL() + + 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 \(format)") + } + } + + func stopRecording() { + audioRecorder?.stop() + updateViews() + cancelTimer() + } + + private func getCurrentLocation(){ + if CLLocationManager.locationServicesEnabled() { + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.startUpdatingLocation() + } + } + + // MARK: - Actions + + @IBAction func updateCurrentTime(_ sender: UISlider) { + if isPlaying{ + pause() + } + + audioPlayer?.currentTime = TimeInterval(sender.value) + updateViews() + } + + // MARK: - IBActions + @IBAction func saveButtonTapped(_ sender: Any) { + guard let recording = self.recordingURL else { return } + guard let location = locationManager.location?.coordinate else { return } + postController.createAudioPost(with: "Audio Post", audioURL: recording, location: location) + } + + @IBAction func recordButtonTapped(_ sender: Any) { + if isRecording { + stopRecording() + } else { + requestPermissionOrStartRecording() + } + } + + @IBAction func playButtonTapped(_ sender: Any) { + if isPlaying { + pause() + } else { + play() + } + } +} + + +extension AudioEntryViewController: AVAudioPlayerDelegate { + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + updateViews() + cancelTimer() + } + + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error = error { + print("Audio Player Error: \(error)") + } + } +} + + +extension AudioEntryViewController: 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("Recoder Player Error: \(error)") + } + } +} diff --git a/experiences/memories/ViewControllers/LoginViewController.swift b/experiences/memories/ViewControllers/LoginViewController.swift new file mode 100644 index 00000000..e40085b1 --- /dev/null +++ b/experiences/memories/ViewControllers/LoginViewController.swift @@ -0,0 +1,73 @@ +// +// LoginViewController.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import AVFoundation +import AVKit + +class LoginViewController: UIViewController { + + // MARK: - IBOutlets + @IBOutlet var backgroundVideoView: UIView! + @IBOutlet var loginView: UIView! + @IBOutlet weak var usernameTextField: UITextField! + + // MARK: - Properties + let player = AVPlayer(url: URL(fileURLWithPath: Bundle.main.path(forResource: "login", ofType: "mov")!)) + var newPlayer = AVPlayerLayer(player: nil) + let postController = PostController.shared + + // MARK: - Lifecycle + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + segueIfUsernameExists() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + setupView() + view.addSubview(loginView) + } + + override func viewDidLayoutSubviews() { + newPlayer.frame = self.backgroundVideoView.bounds + } + // MARK: - Private Methods + + private func setupView() { + newPlayer = AVPlayerLayer(player: player) + newPlayer.videoGravity = AVLayerVideoGravity.resizeAspectFill + + self.backgroundVideoView.layer.addSublayer(newPlayer) + + + player.play() + player.actionAtItemEnd = AVPlayer.ActionAtItemEnd.none + + NotificationCenter.default.addObserver(self, selector: #selector(self.videoDidPlayToEnd(_: )), name: NSNotification.Name(rawValue: "AVPlayerItemDidPlayToEndTimeNotification"), object: player.currentItem) + } + + @objc private func videoDidPlayToEnd(_ notification: Notification) { + let player: AVPlayerItem = notification.object as! AVPlayerItem + player.seek(to: CMTime.zero, completionHandler: nil) + } + + private func segueIfUsernameExists() { + if UserDefaults.standard.string(forKey: "username") != nil { + performSegue(withIdentifier: "MemoriesCollectionSegue", sender: nil) + } + } + + // MARK: - IBAction + @IBAction func loginTapped(_ sender: UIButton) { + guard let username = usernameTextField.text, !username.isEmpty else { return } + UserDefaults.standard.set(username, forKey: "username") + segueIfUsernameExists() + } +} + diff --git a/experiences/memories/ViewControllers/MapViewController.swift b/experiences/memories/ViewControllers/MapViewController.swift new file mode 100644 index 00000000..d1f64099 --- /dev/null +++ b/experiences/memories/ViewControllers/MapViewController.swift @@ -0,0 +1,42 @@ +// +// MapViewController.swift +// memories +// +// Created by Clayton Watkins on 9/11/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import MapKit + +class MapViewController: UIViewController { + // MARK: - IBOutlets + @IBOutlet weak var mapView: MKMapView! + + // MARK: - Properties + var location: CLLocationCoordinate2D? + var postTitle: String? + var postAuthor: String? + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + mapView.mapType = .standard + mapView.register(MKAnnotationView.self, forAnnotationViewWithReuseIdentifier: .annotationReuseIdentifier) + setPinUsingMKPointAnnotation(location: location!) + } + + func setPinUsingMKPointAnnotation(location: CLLocationCoordinate2D){ + let annotation = MKPointAnnotation() + annotation.coordinate = location + annotation.title = postTitle + annotation.subtitle = postAuthor + let coordinateRegion = MKCoordinateRegion(center: annotation.coordinate, latitudinalMeters: 200, longitudinalMeters: 200) + mapView.setRegion(coordinateRegion, animated: true) + mapView.addAnnotation(annotation) + } +} + +extension String { + static let annotationReuseIdentifier = "PostLocationView" +} diff --git a/experiences/memories/ViewControllers/MemoriesCollectionViewController.swift b/experiences/memories/ViewControllers/MemoriesCollectionViewController.swift new file mode 100644 index 00000000..574e195d --- /dev/null +++ b/experiences/memories/ViewControllers/MemoriesCollectionViewController.swift @@ -0,0 +1,160 @@ +// +// MemoriesCollectionViewController.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import MapKit + +class MemoriesCollectionViewController: UICollectionViewController { + + // MARK: - Properties + let locationManager = CLLocationManager.shared + let postController = PostController.shared + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + locationManager.delegate = self + locationManager.requestWhenInUseAuthorization() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + collectionView.reloadData() + } + + // MARK: UICollectionViewDataSource + override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return postController.posts.count + } + + override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let post = postController.posts[indexPath.row] + + if post.image != nil { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PhotoEntryCell", for: indexPath) as? PhotoCollectionViewCell else { return UICollectionViewCell() } + cell.post = post + cell.delegate = self + cell.layer.cornerRadius = 8 + cell.layer.borderWidth = 2 + cell.layer.borderColor = UIColor.systemOrange.cgColor + return cell + } else if post.audioURL != nil { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "AudioEntryCell", for: indexPath) as? AudioCollectionViewCell else { return UICollectionViewCell() } + cell.post = post + cell.delegate = self + cell.layer.cornerRadius = 8 + cell.layer.borderWidth = 2 + cell.layer.borderColor = UIColor.systemOrange.cgColor + return cell + } else if post.entry != nil { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "TextEntryCell", for: indexPath) as? TextCollectionViewCell else { return UICollectionViewCell() } + cell.post = post + cell.delegate = self + cell.layer.cornerRadius = 8 + cell.layer.borderWidth = 2 + cell.layer.borderColor = UIColor.systemOrange.cgColor + return cell + } + return UICollectionViewCell() + } + + // MARK: - Private Methods + + + // MARK: - IBActions + @IBAction func unwindSegue(_ sender: UIStoryboardSegue){} + @IBAction func addPostTapped(_ sender: Any) { + let alert = UIAlertController(title: "New Post", message: "Which kind of post do you want to create?", preferredStyle: .actionSheet) + + let imagePostAction = UIAlertAction(title: "Image", style: .default) { _ in + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "ImagePostController") as! PhotoEntryViewController + self.navigationController?.pushViewController(vc, animated: true) + } + + let audioPostAction = UIAlertAction(title: "Audio", style: .default) { _ in + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "AudioPostController") as! AudioEntryViewController + self.navigationController?.pushViewController(vc, animated: true) + } + + let textPostAction = UIAlertAction(title: "Text", style: .default) { _ in + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "TextPostController") as! TextEntryViewController + self.navigationController?.pushViewController(vc, animated: true) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) + + alert.addAction(imagePostAction) + alert.addAction(audioPostAction) + alert.addAction(textPostAction) + alert.addAction(cancelAction) + + self.present(alert, animated: true, completion: nil) + } + +} + +extension MemoriesCollectionViewController: CLLocationManagerDelegate { + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let locValue: CLLocationCoordinate2D = manager.location?.coordinate else { return } + print("locations = \(locValue.latitude) \(locValue.longitude)") + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print(error) + } +} + +extension CLLocationManager { + static let shared = CLLocationManager() +} + +/* Conforming to our protocols on each UICollectionViewCell so that when we tap our nav button, we are able to get the appropriate indexPath for each cell, and transistionas appropriately to the MapViewController to fill in the blanks and displays location on map with a pin +*/ + +extension MemoriesCollectionViewController: cellIndexPathDelegate { + func locationButtonTapped(cell: PhotoCollectionViewCell) { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "MapViewController") as! MapViewController + self.navigationController?.pushViewController(vc, animated: true) + let indexPath = self.collectionView.indexPath(for: cell) + print(indexPath!.row) + let post = postController.posts[indexPath!.row] + vc.location = post.location + vc.postTitle = post.title + vc.postAuthor = post.author + } +} + +extension MemoriesCollectionViewController: cellIndexPathDelegate2 { + func locationButtonTapped(cell: AudioCollectionViewCell) { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "MapViewController") as! MapViewController + self.navigationController?.pushViewController(vc, animated: true) + let indexPath = self.collectionView.indexPath(for: cell) + let post = postController.posts[indexPath!.row] + vc.location = post.location + vc.postTitle = post.title + vc.postAuthor = post.author + } +} + +extension MemoriesCollectionViewController: cellIndexPathDelegate3 { + func locationButtonTapped(cell: TextCollectionViewCell) { + let storyboard = UIStoryboard(name: "Main", bundle: nil) + let vc = storyboard.instantiateViewController(identifier: "MapViewController") as! MapViewController + self.navigationController?.pushViewController(vc, animated: true) + let indexPath = self.collectionView.indexPath(for: cell) + let post = postController.posts[indexPath!.row] + vc.location = post.location + vc.postTitle = post.title + vc.postAuthor = post.author + } +} diff --git a/experiences/memories/ViewControllers/PhotoEntryViewController.swift b/experiences/memories/ViewControllers/PhotoEntryViewController.swift new file mode 100644 index 00000000..c8a9182d --- /dev/null +++ b/experiences/memories/ViewControllers/PhotoEntryViewController.swift @@ -0,0 +1,98 @@ +// +// PhotoEntryViewController.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import Photos +import MapKit + +class PhotoEntryViewController: UIViewController { + + // MARK: - IBOutlets + @IBOutlet weak var chooseImageButton: UIButton! + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleTextField: UITextField! + + // MARK: - Properties + let postController = PostController.shared + let locationManager = CLLocationManager.shared + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + } + + // MARK: - Private Methods + private func presentImagePickerController() { + guard UIImagePickerController.isSourceTypeAvailable(.photoLibrary) else { + return + } + DispatchQueue.main.async { + let imagePicker = UIImagePickerController() + imagePicker.delegate = self + imagePicker.sourceType = .photoLibrary + + self.present(imagePicker, animated: true, completion: nil) + } + } + + private func getCurrentLocation(){ + if CLLocationManager.locationServicesEnabled() { + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.startUpdatingLocation() + } + } + + // MARK: - IBActions + @IBAction func addPhotoTapped(_ sender: Any) { + let authorizationStatus = PHPhotoLibrary.authorizationStatus() + + switch authorizationStatus { + case .authorized: + presentImagePickerController() + case .notDetermined: + + PHPhotoLibrary.requestAuthorization { (status) in + + guard status == .authorized else { + NSLog("User did not authorize access to the photo library") + return + } + + self.presentImagePickerController() + } + case .denied: + print("Access Denied") + case .restricted: + print("Access Restriced") + default: + break + } + presentImagePickerController() + } + + @IBAction func saveImageTapped(_ sender: Any) { + guard let title = titleTextField.text, !title.isEmpty, + let image = imageView.image else { return } + getCurrentLocation() + guard let location = locationManager.location?.coordinate else { return } + postController.createImagePost(with: title, image: image, location: location) + } +} + +extension PhotoEntryViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + chooseImageButton.setTitle("", for: []) + picker.dismiss(animated: true, completion: nil) + guard let image = info[UIImagePickerController.InfoKey.originalImage] as? UIImage else { return } + imageView.image = image + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true, completion: nil) + } +} diff --git a/experiences/memories/ViewControllers/TextEntryViewController.swift b/experiences/memories/ViewControllers/TextEntryViewController.swift new file mode 100644 index 00000000..e6a8bcee --- /dev/null +++ b/experiences/memories/ViewControllers/TextEntryViewController.swift @@ -0,0 +1,44 @@ +// +// TextEntryViewController.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import MapKit + +class TextEntryViewController: UIViewController { + + // MARK: - IBOutlets + @IBOutlet weak var titleTextField: UITextField! + @IBOutlet weak var entryTextView: UITextView! + + // MARK: - Properties + let postController = PostController.shared + let locationManager = CLLocationManager.shared + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + getCurrentLocation() + } + + // MARK: Private + private func getCurrentLocation(){ + if CLLocationManager.locationServicesEnabled() { + locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters + locationManager.startUpdatingLocation() + } + } + + // MARK: - IBAction + @IBAction func saveEntryTapped(_ sender: Any) { + guard let title = titleTextField.text, !title.isEmpty, + let entry = entryTextView.text, !entry.isEmpty else { return } + guard let location = locationManager.location?.coordinate else { return } + postController.createTextPost(with: title, entry: entry, location: location) + } + +} diff --git a/experiences/memories/Views/AudioCollectionViewCell.swift b/experiences/memories/Views/AudioCollectionViewCell.swift new file mode 100644 index 00000000..753e4dac --- /dev/null +++ b/experiences/memories/Views/AudioCollectionViewCell.swift @@ -0,0 +1,63 @@ +// +// AudioCollectionViewCell.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +import AVFoundation +protocol cellIndexPathDelegate2: AnyObject { + func locationButtonTapped(cell: AudioCollectionViewCell) +} +class AudioCollectionViewCell: UICollectionViewCell { + + // MARK: - IBOutlet + @IBOutlet weak var playButton: UIButton! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + + // MARK: - Properties +// var recordingURL: URL? + var audioPlayer: AVAudioPlayer? + var delegate: cellIndexPathDelegate2? + var post: Post?{ + didSet{ + updateViews() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + } + + private func updateViews() { + playButton.backgroundColor = .systemOrange + playButton.layer.cornerRadius = 25 + } + + func prepareAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory(.playback, options: [.defaultToSpeaker]) + try session.setActive(true, options: []) + } + + @IBAction func playButtonTapped(_ sender: Any) { + do { + try prepareAudioSession() + if let recordingURL = post?.audioURL{ + print(recordingURL) + audioPlayer = try AVAudioPlayer(contentsOf: recordingURL) + guard let audioPlayer = self.audioPlayer else { return } + audioPlayer.play() + } + + } catch { + preconditionFailure("Failure to load audio file at path \(post!.audioURL!): \(error)") + } + } + @IBAction func locationButtonTapped(_ sender: UIButton) { + delegate?.locationButtonTapped(cell: self) + } +} diff --git a/experiences/memories/Views/AudioVisualizer.swift b/experiences/memories/Views/AudioVisualizer.swift new file mode 100644 index 00000000..8bda3552 --- /dev/null +++ b/experiences/memories/Views/AudioVisualizer.swift @@ -0,0 +1,276 @@ +// +// AudioVisualizer.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. 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/experiences/memories/Views/PhotoCollectionViewCell.swift b/experiences/memories/Views/PhotoCollectionViewCell.swift new file mode 100644 index 00000000..aeb620cd --- /dev/null +++ b/experiences/memories/Views/PhotoCollectionViewCell.swift @@ -0,0 +1,51 @@ +// +// PhotoCollectionViewCell.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit +protocol cellIndexPathDelegate: AnyObject { + func locationButtonTapped(cell: PhotoCollectionViewCell) +} +class PhotoCollectionViewCell: UICollectionViewCell { + // MARK: - IBOutlets + @IBOutlet weak var imageView: UIImageView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + + // MARK: - Properties + var post: Post?{ + didSet{ + updateViews() + } + } + var delegate: cellIndexPathDelegate? + + // MARK: - Lifecycle + override func layoutSubviews() { + super.layoutSubviews() + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.image = nil + titleLabel.text = "" + authorLabel.text = "" + } + + // MARK: - Private + private func updateViews() { + guard let post = post else { return } + titleLabel.text = post.title + authorLabel.text = post.author + imageView.image = post.image + } + + // MARK: - IBAction + @IBAction func locationButtonTapped(_ sender: UIButton) { + delegate?.locationButtonTapped(cell: self) + } +} diff --git a/experiences/memories/Views/TextCollectionViewCell.swift b/experiences/memories/Views/TextCollectionViewCell.swift new file mode 100644 index 00000000..770922c4 --- /dev/null +++ b/experiences/memories/Views/TextCollectionViewCell.swift @@ -0,0 +1,54 @@ +// +// TextCollectionViewCell.swift +// memories +// +// Created by Clayton Watkins on 9/10/20. +// Copyright © 2020 Clayton Watkins. All rights reserved. +// + +import UIKit + +protocol cellIndexPathDelegate3: AnyObject { + func locationButtonTapped(cell: TextCollectionViewCell) +} + +class TextCollectionViewCell: UICollectionViewCell { + + // MARK: - IBOutlets + @IBOutlet weak var textView: UITextView! + @IBOutlet weak var titleLabel: UILabel! + @IBOutlet weak var authorLabel: UILabel! + + // MARK: - Properties + var post: Post?{ + didSet{ + updateViews() + } + } + var delegate: cellIndexPathDelegate3? + + // MARK: - Lifecycle + override func layoutSubviews() { + super.layoutSubviews() + } + + override func prepareForReuse() { + super.prepareForReuse() + textView.text = "" + titleLabel.text = "" + authorLabel.text = "" + } + + // MARK: - Private + private func updateViews() { + guard let post = post else { return } + titleLabel.text = post.title + authorLabel.text = post.author + textView.text = post.entry + } + + // MARK: - IBAction + @IBAction func locationButtonTapped(_ sender: UIButton) { + delegate?.locationButtonTapped(cell: self) + } +} diff --git a/experiences/memories/login.mov b/experiences/memories/login.mov new file mode 100644 index 00000000..be223a45 Binary files /dev/null and b/experiences/memories/login.mov differ