From a07b59c7014ee9cfbaa9fc41d07fe26c0c0d0bb8 Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin <36726279+gjorlin@users.noreply.github.com> Date: Fri, 11 Sep 2020 18:01:36 +0200 Subject: [PATCH 1/7] Initial cleaned project --- .gitignore | 92 ++++ Movies/Movies.xcodeproj/project.pbxproj | 478 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Movies/Movies/AppDelegate.swift | 12 + .../AppIcon.appiconset/Contents.json | 98 ++++ Movies/Movies/Assets.xcassets/Contents.json | 6 + .../Movies/Base.lproj/LaunchScreen.storyboard | 25 + Movies/Movies/ContentView.swift | 21 + Movies/Movies/Info.plist | 60 +++ .../Preview Assets.xcassets/Contents.json | 6 + Movies/Movies/SceneDelegate.swift | 21 + Movies/MoviesTests/Info.plist | 22 + Movies/MoviesTests/MoviesTests.swift | 27 + 14 files changed, 883 insertions(+) create mode 100644 .gitignore create mode 100644 Movies/Movies.xcodeproj/project.pbxproj create mode 100644 Movies/Movies.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Movies/Movies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Movies/Movies/AppDelegate.swift create mode 100644 Movies/Movies/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Movies/Movies/Assets.xcassets/Contents.json create mode 100644 Movies/Movies/Base.lproj/LaunchScreen.storyboard create mode 100644 Movies/Movies/ContentView.swift create mode 100644 Movies/Movies/Info.plist create mode 100644 Movies/Movies/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Movies/Movies/SceneDelegate.swift create mode 100644 Movies/MoviesTests/Info.plist create mode 100644 Movies/MoviesTests/MoviesTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5466cb2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +.DS_Store + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ diff --git a/Movies/Movies.xcodeproj/project.pbxproj b/Movies/Movies.xcodeproj/project.pbxproj new file mode 100644 index 00000000..5dc9668d --- /dev/null +++ b/Movies/Movies.xcodeproj/project.pbxproj @@ -0,0 +1,478 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA15D250BC674007654B8 /* AppDelegate.swift */; }; + AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA15F250BC674007654B8 /* SceneDelegate.swift */; }; + AB4AA162250BC674007654B8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA161250BC674007654B8 /* ContentView.swift */; }; + AB4AA164250BC676007654B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA163250BC676007654B8 /* Assets.xcassets */; }; + AB4AA167250BC676007654B8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA166250BC676007654B8 /* Preview Assets.xcassets */; }; + AB4AA16A250BC676007654B8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA168250BC676007654B8 /* LaunchScreen.storyboard */; }; + AB4AA175250BC676007654B8 /* MoviesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA174250BC676007654B8 /* MoviesTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + AB4AA171250BC676007654B8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = AB4AA152250BC674007654B8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AB4AA159250BC674007654B8; + remoteInfo = Movies; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + AB4AA15A250BC674007654B8 /* Movies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Movies.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AB4AA15D250BC674007654B8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + AB4AA15F250BC674007654B8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + AB4AA161250BC674007654B8 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + AB4AA163250BC676007654B8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AB4AA166250BC676007654B8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + AB4AA169250BC676007654B8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + AB4AA16B250BC676007654B8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB4AA170250BC676007654B8 /* MoviesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoviesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AB4AA174250BC676007654B8 /* MoviesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesTests.swift; sourceTree = ""; }; + AB4AA176250BC676007654B8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AB4AA157250BC674007654B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AB4AA16D250BC676007654B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AB4AA151250BC674007654B8 = { + isa = PBXGroup; + children = ( + AB4AA15C250BC674007654B8 /* Movies */, + AB4AA173250BC676007654B8 /* MoviesTests */, + AB4AA15B250BC674007654B8 /* Products */, + ); + sourceTree = ""; + }; + AB4AA15B250BC674007654B8 /* Products */ = { + isa = PBXGroup; + children = ( + AB4AA15A250BC674007654B8 /* Movies.app */, + AB4AA170250BC676007654B8 /* MoviesTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + AB4AA15C250BC674007654B8 /* Movies */ = { + isa = PBXGroup; + children = ( + AB4AA15D250BC674007654B8 /* AppDelegate.swift */, + AB4AA15F250BC674007654B8 /* SceneDelegate.swift */, + AB4AA161250BC674007654B8 /* ContentView.swift */, + AB4AA163250BC676007654B8 /* Assets.xcassets */, + AB4AA168250BC676007654B8 /* LaunchScreen.storyboard */, + AB4AA16B250BC676007654B8 /* Info.plist */, + AB4AA165250BC676007654B8 /* Preview Content */, + ); + path = Movies; + sourceTree = ""; + }; + AB4AA165250BC676007654B8 /* Preview Content */ = { + isa = PBXGroup; + children = ( + AB4AA166250BC676007654B8 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + AB4AA173250BC676007654B8 /* MoviesTests */ = { + isa = PBXGroup; + children = ( + AB4AA174250BC676007654B8 /* MoviesTests.swift */, + AB4AA176250BC676007654B8 /* Info.plist */, + ); + path = MoviesTests; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AB4AA159250BC674007654B8 /* Movies */ = { + isa = PBXNativeTarget; + buildConfigurationList = AB4AA179250BC676007654B8 /* Build configuration list for PBXNativeTarget "Movies" */; + buildPhases = ( + AB4AA156250BC674007654B8 /* Sources */, + AB4AA157250BC674007654B8 /* Frameworks */, + AB4AA158250BC674007654B8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Movies; + productName = Movies; + productReference = AB4AA15A250BC674007654B8 /* Movies.app */; + productType = "com.apple.product-type.application"; + }; + AB4AA16F250BC676007654B8 /* MoviesTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AB4AA17C250BC676007654B8 /* Build configuration list for PBXNativeTarget "MoviesTests" */; + buildPhases = ( + AB4AA16C250BC676007654B8 /* Sources */, + AB4AA16D250BC676007654B8 /* Frameworks */, + AB4AA16E250BC676007654B8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AB4AA172250BC676007654B8 /* PBXTargetDependency */, + ); + name = MoviesTests; + productName = MoviesTests; + productReference = AB4AA170250BC676007654B8 /* MoviesTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AB4AA152250BC674007654B8 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1160; + LastUpgradeCheck = 1160; + ORGANIZATIONNAME = "Gustaf Jorlin"; + TargetAttributes = { + AB4AA159250BC674007654B8 = { + CreatedOnToolsVersion = 11.6; + }; + AB4AA16F250BC676007654B8 = { + CreatedOnToolsVersion = 11.6; + TestTargetID = AB4AA159250BC674007654B8; + }; + }; + }; + buildConfigurationList = AB4AA155250BC674007654B8 /* Build configuration list for PBXProject "Movies" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AB4AA151250BC674007654B8; + productRefGroup = AB4AA15B250BC674007654B8 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AB4AA159250BC674007654B8 /* Movies */, + AB4AA16F250BC676007654B8 /* MoviesTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AB4AA158250BC674007654B8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB4AA16A250BC676007654B8 /* LaunchScreen.storyboard in Resources */, + AB4AA167250BC676007654B8 /* Preview Assets.xcassets in Resources */, + AB4AA164250BC676007654B8 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AB4AA16E250BC676007654B8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AB4AA156250BC674007654B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */, + AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */, + AB4AA162250BC674007654B8 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AB4AA16C250BC676007654B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB4AA175250BC676007654B8 /* MoviesTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + AB4AA172250BC676007654B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AB4AA159250BC674007654B8 /* Movies */; + targetProxy = AB4AA171250BC676007654B8 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + AB4AA168250BC676007654B8 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + AB4AA169250BC676007654B8 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + AB4AA177250BC676007654B8 /* 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.6; + 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; + }; + AB4AA178250BC676007654B8 /* 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.6; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + AB4AA17A250BC676007654B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"Movies/Preview Content\""; + DEVELOPMENT_TEAM = 88S3B6XZEL; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = Movies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bitcycle.Movies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + AB4AA17B250BC676007654B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"Movies/Preview Content\""; + DEVELOPMENT_TEAM = 88S3B6XZEL; + ENABLE_PREVIEWS = YES; + INFOPLIST_FILE = Movies/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bitcycle.Movies; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + AB4AA17D250BC676007654B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 88S3B6XZEL; + INFOPLIST_FILE = MoviesTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bitcycle.MoviesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Movies.app/Movies"; + }; + name = Debug; + }; + AB4AA17E250BC676007654B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 88S3B6XZEL; + INFOPLIST_FILE = MoviesTests/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.bitcycle.MoviesTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Movies.app/Movies"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AB4AA155250BC674007654B8 /* Build configuration list for PBXProject "Movies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB4AA177250BC676007654B8 /* Debug */, + AB4AA178250BC676007654B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AB4AA179250BC676007654B8 /* Build configuration list for PBXNativeTarget "Movies" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB4AA17A250BC676007654B8 /* Debug */, + AB4AA17B250BC676007654B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AB4AA17C250BC676007654B8 /* Build configuration list for PBXNativeTarget "MoviesTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AB4AA17D250BC676007654B8 /* Debug */, + AB4AA17E250BC676007654B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AB4AA152250BC674007654B8 /* Project object */; +} diff --git a/Movies/Movies.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Movies/Movies.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..3071f8f0 --- /dev/null +++ b/Movies/Movies.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Movies/Movies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Movies/Movies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/Movies/Movies.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Movies/Movies/AppDelegate.swift b/Movies/Movies/AppDelegate.swift new file mode 100644 index 00000000..e89e08f7 --- /dev/null +++ b/Movies/Movies/AppDelegate.swift @@ -0,0 +1,12 @@ +import UIKit + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return true + } +} diff --git a/Movies/Movies/Assets.xcassets/AppIcon.appiconset/Contents.json b/Movies/Movies/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9221b9bb --- /dev/null +++ b/Movies/Movies/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/Movies/Movies/Assets.xcassets/Contents.json b/Movies/Movies/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Movies/Movies/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movies/Movies/Base.lproj/LaunchScreen.storyboard b/Movies/Movies/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..865e9329 --- /dev/null +++ b/Movies/Movies/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Movies/Movies/ContentView.swift b/Movies/Movies/ContentView.swift new file mode 100644 index 00000000..ca7f0d92 --- /dev/null +++ b/Movies/Movies/ContentView.swift @@ -0,0 +1,21 @@ +// +// ContentView.swift +// Movies +// +// Created by Gustaf Jorlin on 2020-09-11. +// Copyright © 2020 Gustaf Jorlin. All rights reserved. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + Text("Hello, World!") + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/Movies/Movies/Info.plist b/Movies/Movies/Info.plist new file mode 100644 index 00000000..9742bf0f --- /dev/null +++ b/Movies/Movies/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Movies/Movies/Preview Content/Preview Assets.xcassets/Contents.json b/Movies/Movies/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/Movies/Movies/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Movies/Movies/SceneDelegate.swift b/Movies/Movies/SceneDelegate.swift new file mode 100644 index 00000000..9e2977dd --- /dev/null +++ b/Movies/Movies/SceneDelegate.swift @@ -0,0 +1,21 @@ +import SwiftUI +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene( + _ scene: UIScene, willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + let contentView = ContentView() + + if let windowScene = scene as? UIWindowScene { + let window = UIWindow(windowScene: windowScene) + window.rootViewController = UIHostingController(rootView: contentView) + self.window = window + window.makeKeyAndVisible() + } + } +} diff --git a/Movies/MoviesTests/Info.plist b/Movies/MoviesTests/Info.plist new file mode 100644 index 00000000..64d65ca4 --- /dev/null +++ b/Movies/MoviesTests/Info.plist @@ -0,0 +1,22 @@ + + + + + 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 + + diff --git a/Movies/MoviesTests/MoviesTests.swift b/Movies/MoviesTests/MoviesTests.swift new file mode 100644 index 00000000..e155ad6a --- /dev/null +++ b/Movies/MoviesTests/MoviesTests.swift @@ -0,0 +1,27 @@ +import XCTest + +@testable import Movies + +class MoviesTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} From 1dd69d1538c4a408779e071c206184a4f32738ef Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin Date: Sat, 12 Sep 2020 06:40:57 +0200 Subject: [PATCH 2/7] added gitattributes --- Movies/.gitattributes | 1 + Movies/MoviesTests/MoviesTests.swift | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) create mode 100644 Movies/.gitattributes diff --git a/Movies/.gitattributes b/Movies/.gitattributes new file mode 100644 index 00000000..854d90ba --- /dev/null +++ b/Movies/.gitattributes @@ -0,0 +1 @@ +*.pbxproj merge=union \ No newline at end of file diff --git a/Movies/MoviesTests/MoviesTests.swift b/Movies/MoviesTests/MoviesTests.swift index e155ad6a..baa8b31a 100644 --- a/Movies/MoviesTests/MoviesTests.swift +++ b/Movies/MoviesTests/MoviesTests.swift @@ -1,7 +1,5 @@ import XCTest -@testable import Movies - class MoviesTests: XCTestCase { override func setUpWithError() throws { From 1ad47c13d6accde6d5492166529649c722923164 Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin <36726279+gjorlin@users.noreply.github.com> Date: Sat, 12 Sep 2020 07:30:26 +0200 Subject: [PATCH 3/7] Project structure and Client Movie fetch (#2) * Initial project * project cleanup * Added LiveClient with movie fetch * Some refactoring * added gitattributes * trying to resolve pbxproj confilct --- Movies/.gitattributes | 2 +- Movies/Movies.xcodeproj/project.pbxproj | 61 +++++++++++++--- Movies/Movies/Client/Client.swift | 7 ++ Movies/Movies/Client/LiveClient.swift | 93 +++++++++++++++++++++++++ Movies/Movies/Models/Error.swift | 8 +++ Movies/Movies/Models/Movie.swift | 9 +++ Movies/Movies/Models/MovieSorting.swift | 7 ++ Movies/MoviesTests/ClientTests.swift | 34 +++++++++ 8 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 Movies/Movies/Client/Client.swift create mode 100644 Movies/Movies/Client/LiveClient.swift create mode 100644 Movies/Movies/Models/Error.swift create mode 100644 Movies/Movies/Models/Movie.swift create mode 100644 Movies/Movies/Models/MovieSorting.swift create mode 100644 Movies/MoviesTests/ClientTests.swift diff --git a/Movies/.gitattributes b/Movies/.gitattributes index 854d90ba..8e45d030 100644 --- a/Movies/.gitattributes +++ b/Movies/.gitattributes @@ -1 +1 @@ -*.pbxproj merge=union \ No newline at end of file +*.pbxproj merge=union diff --git a/Movies/Movies.xcodeproj/project.pbxproj b/Movies/Movies.xcodeproj/project.pbxproj index 5dc9668d..2a32e584 100644 --- a/Movies/Movies.xcodeproj/project.pbxproj +++ b/Movies/Movies.xcodeproj/project.pbxproj @@ -13,7 +13,18 @@ AB4AA164250BC676007654B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA163250BC676007654B8 /* Assets.xcassets */; }; AB4AA167250BC676007654B8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA166250BC676007654B8 /* Preview Assets.xcassets */; }; AB4AA16A250BC676007654B8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA168250BC676007654B8 /* LaunchScreen.storyboard */; }; - AB4AA175250BC676007654B8 /* MoviesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA174250BC676007654B8 /* MoviesTests.swift */; }; + AB4AA180250BD795007654B8 /* ClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA17F250BD795007654B8 /* ClientTests.swift */; }; + AB4AA182250BE500007654B8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA15D250BC674007654B8 /* AppDelegate.swift */; }; + AB4AA185250BE53D007654B8 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA184250BE53D007654B8 /* Client.swift */; }; + AB4AA187250BE649007654B8 /* LiveClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA186250BE649007654B8 /* LiveClient.swift */; }; + AB4AA188250BF05A007654B8 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA184250BE53D007654B8 /* Client.swift */; }; + AB4AA189250BF05A007654B8 /* LiveClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA186250BE649007654B8 /* LiveClient.swift */; }; + AB4AA18C250BF314007654B8 /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18B250BF314007654B8 /* Movie.swift */; }; + AB4AA18D250BF8AE007654B8 /* Movie.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18B250BF314007654B8 /* Movie.swift */; }; + AB4AA18F250C7FB9007654B8 /* MovieSorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */; }; + AB4AA191250C7FC3007654B8 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA190250C7FC3007654B8 /* Error.swift */; }; + AB4AA192250C7FF4007654B8 /* MovieSorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */; }; + AB4AA193250C7FF4007654B8 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA190250C7FC3007654B8 /* Error.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -36,8 +47,13 @@ AB4AA169250BC676007654B8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; AB4AA16B250BC676007654B8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AB4AA170250BC676007654B8 /* MoviesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MoviesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - AB4AA174250BC676007654B8 /* MoviesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesTests.swift; sourceTree = ""; }; AB4AA176250BC676007654B8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB4AA17F250BD795007654B8 /* ClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientTests.swift; sourceTree = ""; }; + AB4AA184250BE53D007654B8 /* Client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Client.swift; sourceTree = ""; }; + AB4AA186250BE649007654B8 /* LiveClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveClient.swift; sourceTree = ""; }; + AB4AA18B250BF314007654B8 /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; + AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSorting.swift; sourceTree = ""; }; + AB4AA190250C7FC3007654B8 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -79,6 +95,8 @@ AB4AA15C250BC674007654B8 /* Movies */ = { isa = PBXGroup; children = ( + AB4AA18A250BF309007654B8 /* Models */, + AB4AA183250BE52E007654B8 /* Client */, AB4AA15D250BC674007654B8 /* AppDelegate.swift */, AB4AA15F250BC674007654B8 /* SceneDelegate.swift */, AB4AA161250BC674007654B8 /* ContentView.swift */, @@ -101,12 +119,31 @@ AB4AA173250BC676007654B8 /* MoviesTests */ = { isa = PBXGroup; children = ( - AB4AA174250BC676007654B8 /* MoviesTests.swift */, + AB4AA17F250BD795007654B8 /* ClientTests.swift */, AB4AA176250BC676007654B8 /* Info.plist */, ); path = MoviesTests; sourceTree = ""; }; + AB4AA183250BE52E007654B8 /* Client */ = { + isa = PBXGroup; + children = ( + AB4AA184250BE53D007654B8 /* Client.swift */, + AB4AA186250BE649007654B8 /* LiveClient.swift */, + ); + path = Client; + sourceTree = ""; + }; + AB4AA18A250BF309007654B8 /* Models */ = { + isa = PBXGroup; + children = ( + AB4AA18B250BF314007654B8 /* Movie.swift */, + AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */, + AB4AA190250C7FC3007654B8 /* Error.swift */, + ); + path = Models; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -160,7 +197,6 @@ }; AB4AA16F250BC676007654B8 = { CreatedOnToolsVersion = 11.6; - TestTargetID = AB4AA159250BC674007654B8; }; }; }; @@ -208,8 +244,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB4AA191250C7FC3007654B8 /* Error.swift in Sources */, AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */, + AB4AA18F250C7FB9007654B8 /* MovieSorting.swift in Sources */, AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */, + AB4AA18C250BF314007654B8 /* Movie.swift in Sources */, + AB4AA185250BE53D007654B8 /* Client.swift in Sources */, + AB4AA187250BE649007654B8 /* LiveClient.swift in Sources */, AB4AA162250BC674007654B8 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -218,7 +259,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AB4AA175250BC676007654B8 /* MoviesTests.swift in Sources */, + AB4AA182250BE500007654B8 /* AppDelegate.swift in Sources */, + AB4AA180250BD795007654B8 /* ClientTests.swift in Sources */, + AB4AA18D250BF8AE007654B8 /* Movie.swift in Sources */, + AB4AA188250BF05A007654B8 /* Client.swift in Sources */, + AB4AA189250BF05A007654B8 /* LiveClient.swift in Sources */, + AB4AA193250C7FF4007654B8 /* Error.swift in Sources */, + AB4AA192250C7FF4007654B8 /* MovieSorting.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -402,7 +449,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 88S3B6XZEL; INFOPLIST_FILE = MoviesTests/Info.plist; @@ -416,7 +462,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Movies.app/Movies"; }; name = Debug; }; @@ -424,7 +469,6 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = 88S3B6XZEL; INFOPLIST_FILE = MoviesTests/Info.plist; @@ -438,7 +482,6 @@ PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Movies.app/Movies"; }; name = Release; }; diff --git a/Movies/Movies/Client/Client.swift b/Movies/Movies/Client/Client.swift new file mode 100644 index 00000000..996e860c --- /dev/null +++ b/Movies/Movies/Client/Client.swift @@ -0,0 +1,7 @@ +import UIKit +import Combine + +/// The interface for the movie API. +protocol Client { + func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],ClientError> +} diff --git a/Movies/Movies/Client/LiveClient.swift b/Movies/Movies/Client/LiveClient.swift new file mode 100644 index 00000000..454625b0 --- /dev/null +++ b/Movies/Movies/Client/LiveClient.swift @@ -0,0 +1,93 @@ +import UIKit +import Combine + + +/// The `LiveClient` that uses https://www.themoviedb.org/ +/// +/// Remember to set the`key` property to your api key, see: https://developers.themoviedb.org/3/getting-started/authentication +class LiveClient: Client { + #warning ("Remove/Insert API Secret") + let key = "" + let session = URLSession.shared + + func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],ClientError> { + let url = URL(string: """ + https://api.themoviedb.org/3/discover/movie?api_key=\(key)\ + &language=en-US\ + \(sorting.queryString)\ + &include_adult=false\ + &include_video=false&page=1 + """)! + return session + .dataTaskPublisher(for: url) + .tryMap() { element -> Data in + guard + let httpResponse = element.response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } + return element.data + } + .decode(type: ListResponse.self, decoder: JSONDecoder.convertFromSnakeCase) + .map { $0.results.map { $0.movie } } + .mapError { error -> ClientError in + switch error { + case let error as Swift.DecodingError: + return .decoding(error) + case let error as URLError: + return .url(error) + default: + return .other(error) + } + } + .eraseToAnyPublisher() + } +} + +extension LiveClient { + /// An intermediate struct for converting a API JSON response into `Movie`. + struct ListResponse: Codable { + struct MovieResponse: Codable { + var id: Int + var title: String + var voteAverage: Double + var popularity: Double + var releaseDate: String + + /// Maps a `MovieResponse` to a `Movie`. + var movie: Movie { + .init( + id: id, + title: title, + rating: voteAverage, + popularity: popularity, + releaseDate: releaseDate + ) + } + } + var page: Int + var totalResults: Int + var totalPages: Int + var results: [MovieResponse] + } +} + +fileprivate extension MovieSorting { + /// Translates a `MovieSorting` to the corresponding query string used by the endpoint. + var queryString: String { + switch self { + case .popularity: + return "&sort_by=popularity.desc" + case .rating(let voteCountGreaterThan): + return "&sort_by=vote_average.desc&vote_count.gte=\(voteCountGreaterThan)" + } + } +} + +fileprivate extension JSONDecoder { + /// A `JSONDecoder` configured to automatically converts from snake_case to camelCase. + static var convertFromSnakeCase: JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + } +} diff --git a/Movies/Movies/Models/Error.swift b/Movies/Movies/Models/Error.swift new file mode 100644 index 00000000..2088ffff --- /dev/null +++ b/Movies/Movies/Models/Error.swift @@ -0,0 +1,8 @@ +import Foundation + +enum ClientError: Error { + case other(Error) + case decoding(DecodingError) + case url(URLError) +} + diff --git a/Movies/Movies/Models/Movie.swift b/Movies/Movies/Models/Movie.swift new file mode 100644 index 00000000..93b1c792 --- /dev/null +++ b/Movies/Movies/Models/Movie.swift @@ -0,0 +1,9 @@ +import Foundation + +struct Movie { + var id: Int + var title: String + var rating: Double + var popularity: Double + var releaseDate: String +} diff --git a/Movies/Movies/Models/MovieSorting.swift b/Movies/Movies/Models/MovieSorting.swift new file mode 100644 index 00000000..d3180d67 --- /dev/null +++ b/Movies/Movies/Models/MovieSorting.swift @@ -0,0 +1,7 @@ +import Foundation + +enum MovieSorting { + case popularity + case rating(voteCountGreaterThan: Int) +} + diff --git a/Movies/MoviesTests/ClientTests.swift b/Movies/MoviesTests/ClientTests.swift new file mode 100644 index 00000000..64c5f700 --- /dev/null +++ b/Movies/MoviesTests/ClientTests.swift @@ -0,0 +1,34 @@ +import XCTest +import Combine + +class ClientTests: XCTestCase { + + var cancellables = Set() + + override func tearDownWithError() throws { + cancellables.removeAll() + } + + func testLiveClientMovieDecodable() throws { + let client = LiveClient() + let expectation = self.expectation(description: "receive value") + client + .movies(sorting: .rating(voteCountGreaterThan: 200)) + .sink( + receiveCompletion: { + switch $0 { + case .failure(let error): + XCTFail("\(error)") + case .finished: + break + } + }, + receiveValue: { value in + print(value.count) + expectation.fulfill() + }) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } +} From 8b51fcd2bb57f231766fc366087d3e9f1922733e Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin <36726279+gjorlin@users.noreply.github.com> Date: Sat, 12 Sep 2020 09:09:34 +0200 Subject: [PATCH 4/7] MovieListView (#3) * Added MovieListView * using /movie/popular instead of /discover API * Removed api secret --- Movies/Movies.xcodeproj/project.pbxproj | 15 +++++-- Movies/Movies/Client/Client.swift | 2 +- Movies/Movies/Client/LiveClient.swift | 20 +++++---- Movies/Movies/ContentView.swift | 21 --------- Movies/Movies/Models/Error.swift | 5 ++- Movies/Movies/Models/Movie.swift | 2 +- Movies/Movies/Models/MovieSorting.swift | 5 ++- Movies/Movies/SceneDelegate.swift | 4 +- Movies/Movies/UI/MovieListView.swift | 58 +++++++++++++++++++++++++ Movies/MoviesTests/ClientTests.swift | 2 +- 10 files changed, 92 insertions(+), 42 deletions(-) delete mode 100644 Movies/Movies/ContentView.swift create mode 100644 Movies/Movies/UI/MovieListView.swift diff --git a/Movies/Movies.xcodeproj/project.pbxproj b/Movies/Movies.xcodeproj/project.pbxproj index 2a32e584..c63a9066 100644 --- a/Movies/Movies.xcodeproj/project.pbxproj +++ b/Movies/Movies.xcodeproj/project.pbxproj @@ -9,7 +9,6 @@ /* Begin PBXBuildFile section */ AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA15D250BC674007654B8 /* AppDelegate.swift */; }; AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA15F250BC674007654B8 /* SceneDelegate.swift */; }; - AB4AA162250BC674007654B8 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA161250BC674007654B8 /* ContentView.swift */; }; AB4AA164250BC676007654B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA163250BC676007654B8 /* Assets.xcassets */; }; AB4AA167250BC676007654B8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA166250BC676007654B8 /* Preview Assets.xcassets */; }; AB4AA16A250BC676007654B8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AB4AA168250BC676007654B8 /* LaunchScreen.storyboard */; }; @@ -25,6 +24,7 @@ AB4AA191250C7FC3007654B8 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA190250C7FC3007654B8 /* Error.swift */; }; AB4AA192250C7FF4007654B8 /* MovieSorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */; }; AB4AA193250C7FF4007654B8 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA190250C7FC3007654B8 /* Error.swift */; }; + AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA195250C97CF007654B8 /* MovieListView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -41,7 +41,6 @@ AB4AA15A250BC674007654B8 /* Movies.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Movies.app; sourceTree = BUILT_PRODUCTS_DIR; }; AB4AA15D250BC674007654B8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; AB4AA15F250BC674007654B8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - AB4AA161250BC674007654B8 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; AB4AA163250BC676007654B8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; AB4AA166250BC676007654B8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; AB4AA169250BC676007654B8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -54,6 +53,7 @@ AB4AA18B250BF314007654B8 /* Movie.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movie.swift; sourceTree = ""; }; AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSorting.swift; sourceTree = ""; }; AB4AA190250C7FC3007654B8 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; + AB4AA195250C97CF007654B8 /* MovieListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,9 +97,9 @@ children = ( AB4AA18A250BF309007654B8 /* Models */, AB4AA183250BE52E007654B8 /* Client */, + AB4AA194250C97A5007654B8 /* UI */, AB4AA15D250BC674007654B8 /* AppDelegate.swift */, AB4AA15F250BC674007654B8 /* SceneDelegate.swift */, - AB4AA161250BC674007654B8 /* ContentView.swift */, AB4AA163250BC676007654B8 /* Assets.xcassets */, AB4AA168250BC676007654B8 /* LaunchScreen.storyboard */, AB4AA16B250BC676007654B8 /* Info.plist */, @@ -144,6 +144,14 @@ path = Models; sourceTree = ""; }; + AB4AA194250C97A5007654B8 /* UI */ = { + isa = PBXGroup; + children = ( + AB4AA195250C97CF007654B8 /* MovieListView.swift */, + ); + path = UI; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -246,6 +254,7 @@ files = ( AB4AA191250C7FC3007654B8 /* Error.swift in Sources */, AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */, + AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */, AB4AA18F250C7FB9007654B8 /* MovieSorting.swift in Sources */, AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */, AB4AA18C250BF314007654B8 /* Movie.swift in Sources */, diff --git a/Movies/Movies/Client/Client.swift b/Movies/Movies/Client/Client.swift index 996e860c..dc452bfc 100644 --- a/Movies/Movies/Client/Client.swift +++ b/Movies/Movies/Client/Client.swift @@ -3,5 +3,5 @@ import Combine /// The interface for the movie API. protocol Client { - func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],ClientError> + func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> } diff --git a/Movies/Movies/Client/LiveClient.swift b/Movies/Movies/Client/LiveClient.swift index 454625b0..0fdc3bc1 100644 --- a/Movies/Movies/Client/LiveClient.swift +++ b/Movies/Movies/Client/LiveClient.swift @@ -10,13 +10,14 @@ class LiveClient: Client { let key = "" let session = URLSession.shared - func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],ClientError> { + func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> { + // https://api.themoviedb.org/3/movie/popular?api_key=<>&language=en-US&page=1 let url = URL(string: """ - https://api.themoviedb.org/3/discover/movie?api_key=\(key)\ + https://api.themoviedb.org/3/movie/\ + \(sorting.queryString)?\ + api_key=\(key)\ &language=en-US\ - \(sorting.queryString)\ - &include_adult=false\ - &include_video=false&page=1 + &page=1 """)! return session .dataTaskPublisher(for: url) @@ -29,7 +30,7 @@ class LiveClient: Client { } .decode(type: ListResponse.self, decoder: JSONDecoder.convertFromSnakeCase) .map { $0.results.map { $0.movie } } - .mapError { error -> ClientError in + .mapError { error -> Error in switch error { case let error as Swift.DecodingError: return .decoding(error) @@ -39,6 +40,7 @@ class LiveClient: Client { return .other(error) } } + .receive(on: RunLoop.main) .eraseToAnyPublisher() } } @@ -76,9 +78,9 @@ fileprivate extension MovieSorting { var queryString: String { switch self { case .popularity: - return "&sort_by=popularity.desc" - case .rating(let voteCountGreaterThan): - return "&sort_by=vote_average.desc&vote_count.gte=\(voteCountGreaterThan)" + return "popular" + case .rating: + return "top_rated" } } } diff --git a/Movies/Movies/ContentView.swift b/Movies/Movies/ContentView.swift deleted file mode 100644 index ca7f0d92..00000000 --- a/Movies/Movies/ContentView.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// ContentView.swift -// Movies -// -// Created by Gustaf Jorlin on 2020-09-11. -// Copyright © 2020 Gustaf Jorlin. All rights reserved. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - Text("Hello, World!") - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/Movies/Movies/Models/Error.swift b/Movies/Movies/Models/Error.swift index 2088ffff..d6e842e8 100644 --- a/Movies/Movies/Models/Error.swift +++ b/Movies/Movies/Models/Error.swift @@ -1,7 +1,8 @@ import Foundation -enum ClientError: Error { - case other(Error) +/// The general error type used by the `Movies` application. +enum Error: Swift.Error { + case other(Swift.Error) case decoding(DecodingError) case url(URLError) } diff --git a/Movies/Movies/Models/Movie.swift b/Movies/Movies/Models/Movie.swift index 93b1c792..d46e7b53 100644 --- a/Movies/Movies/Models/Movie.swift +++ b/Movies/Movies/Models/Movie.swift @@ -1,6 +1,6 @@ import Foundation -struct Movie { +struct Movie: Identifiable { var id: Int var title: String var rating: Double diff --git a/Movies/Movies/Models/MovieSorting.swift b/Movies/Movies/Models/MovieSorting.swift index d3180d67..b8b011ab 100644 --- a/Movies/Movies/Models/MovieSorting.swift +++ b/Movies/Movies/Models/MovieSorting.swift @@ -1,7 +1,8 @@ import Foundation -enum MovieSorting { +enum MovieSorting: String, CaseIterable, Identifiable { case popularity - case rating(voteCountGreaterThan: Int) + case rating + var id: String { self.rawValue } } diff --git a/Movies/Movies/SceneDelegate.swift b/Movies/Movies/SceneDelegate.swift index 9e2977dd..2b82ccf9 100644 --- a/Movies/Movies/SceneDelegate.swift +++ b/Movies/Movies/SceneDelegate.swift @@ -9,11 +9,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { - let contentView = ContentView() + let view = MovieListView(viewModel: .init(client: LiveClient())) if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) - window.rootViewController = UIHostingController(rootView: contentView) + window.rootViewController = UIHostingController(rootView: view) self.window = window window.makeKeyAndVisible() } diff --git a/Movies/Movies/UI/MovieListView.swift b/Movies/Movies/UI/MovieListView.swift new file mode 100644 index 00000000..575ee31a --- /dev/null +++ b/Movies/Movies/UI/MovieListView.swift @@ -0,0 +1,58 @@ +import SwiftUI +import Combine + +class MovieListViewModel: ObservableObject { + let client: Client + @Published var movieSorting: MovieSorting = .rating + @Published var movies: [Movie] = [] + @Published var hasFailed: Error? + + private var cancellables = Set() + + init(client: Client) { + self.client = client + + $movieSorting + .setFailureType(to: Error.self) + .flatMap { self.client.movies(sorting: $0) } + .sink( + receiveCompletion: { + switch $0 { + case .failure(let error): + self.hasFailed = error + case .finished: + break + } + }, + receiveValue: { self.movies = $0 } ) + .store(in: &cancellables) + + } +} + +struct MovieListView: View { + @ObservedObject var viewModel: MovieListViewModel + var body: some View { + List { + Section(header: + Picker("", selection: self.$viewModel.movieSorting) { + ForEach.init(MovieSorting.allCases) { sorting in + Text(sorting.rawValue.capitalized).tag(sorting) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding()) + { + ForEach(viewModel.movies) { movie in + Text("\(movie.title)") + } + } + } + } +} + +struct MovieListView_Previews: PreviewProvider { + static var previews: some View { + MovieListView(viewModel: .init(client: LiveClient())) + } +} diff --git a/Movies/MoviesTests/ClientTests.swift b/Movies/MoviesTests/ClientTests.swift index 64c5f700..9a56a655 100644 --- a/Movies/MoviesTests/ClientTests.swift +++ b/Movies/MoviesTests/ClientTests.swift @@ -13,7 +13,7 @@ class ClientTests: XCTestCase { let client = LiveClient() let expectation = self.expectation(description: "receive value") client - .movies(sorting: .rating(voteCountGreaterThan: 200)) + .movies(sorting: .rating) .sink( receiveCompletion: { switch $0 { From 9886da6e3d6ee32f5101b2c650499096d3eb98cb Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin <36726279+gjorlin@users.noreply.github.com> Date: Sat, 12 Sep 2020 12:24:07 +0200 Subject: [PATCH 5/7] Feature/images (#4) * added api secret * added poster images * removed api key --- Movies/Movies.xcodeproj/project.pbxproj | 11 ++- Movies/Movies/Client/Client.swift | 1 + Movies/Movies/Client/LiveClient.swift | 90 +++++++++++++++++++++++-- Movies/Movies/Models/Error.swift | 1 + Movies/Movies/Models/ImageSize.swift | 6 ++ Movies/Movies/Models/Movie.swift | 3 +- Movies/Movies/Models/MovieSorting.swift | 2 +- Movies/Movies/SceneDelegate.swift | 6 +- Movies/Movies/UI/MovieListRow.swift | 76 +++++++++++++++++++++ Movies/Movies/UI/MovieListView.swift | 12 +++- Movies/MoviesTests/ClientTests.swift | 26 +++++++ 11 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 Movies/Movies/Models/ImageSize.swift create mode 100644 Movies/Movies/UI/MovieListRow.swift diff --git a/Movies/Movies.xcodeproj/project.pbxproj b/Movies/Movies.xcodeproj/project.pbxproj index c63a9066..517791fc 100644 --- a/Movies/Movies.xcodeproj/project.pbxproj +++ b/Movies/Movies.xcodeproj/project.pbxproj @@ -25,6 +25,9 @@ AB4AA192250C7FF4007654B8 /* MovieSorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */; }; AB4AA193250C7FF4007654B8 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA190250C7FC3007654B8 /* Error.swift */; }; AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA195250C97CF007654B8 /* MovieListView.swift */; }; + AB91ACB8250CB43E00262B07 /* ImageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACB7250CB43E00262B07 /* ImageSize.swift */; }; + AB91ACB9250CB7A500262B07 /* ImageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACB7250CB43E00262B07 /* ImageSize.swift */; }; + AB91ACBB250CBD6800262B07 /* MovieListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACBA250CBD6800262B07 /* MovieListRow.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -54,6 +57,8 @@ AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSorting.swift; sourceTree = ""; }; AB4AA190250C7FC3007654B8 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; AB4AA195250C97CF007654B8 /* MovieListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListView.swift; sourceTree = ""; }; + AB91ACB7250CB43E00262B07 /* ImageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSize.swift; sourceTree = ""; }; + AB91ACBA250CBD6800262B07 /* MovieListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListRow.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -140,6 +145,7 @@ AB4AA18B250BF314007654B8 /* Movie.swift */, AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */, AB4AA190250C7FC3007654B8 /* Error.swift */, + AB91ACB7250CB43E00262B07 /* ImageSize.swift */, ); path = Models; sourceTree = ""; @@ -148,6 +154,7 @@ isa = PBXGroup; children = ( AB4AA195250C97CF007654B8 /* MovieListView.swift */, + AB91ACBA250CBD6800262B07 /* MovieListRow.swift */, ); path = UI; sourceTree = ""; @@ -256,11 +263,12 @@ AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */, AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */, AB4AA18F250C7FB9007654B8 /* MovieSorting.swift in Sources */, + AB91ACB8250CB43E00262B07 /* ImageSize.swift in Sources */, + AB91ACBB250CBD6800262B07 /* MovieListRow.swift in Sources */, AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */, AB4AA18C250BF314007654B8 /* Movie.swift in Sources */, AB4AA185250BE53D007654B8 /* Client.swift in Sources */, AB4AA187250BE649007654B8 /* LiveClient.swift in Sources */, - AB4AA162250BC674007654B8 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -268,6 +276,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB91ACB9250CB7A500262B07 /* ImageSize.swift in Sources */, AB4AA182250BE500007654B8 /* AppDelegate.swift in Sources */, AB4AA180250BD795007654B8 /* ClientTests.swift in Sources */, AB4AA18D250BF8AE007654B8 /* Movie.swift in Sources */, diff --git a/Movies/Movies/Client/Client.swift b/Movies/Movies/Client/Client.swift index dc452bfc..0c98ace2 100644 --- a/Movies/Movies/Client/Client.swift +++ b/Movies/Movies/Client/Client.swift @@ -4,4 +4,5 @@ import Combine /// The interface for the movie API. protocol Client { func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> + func poster(posterPath: String, size: ImageSize) -> AnyPublisher } diff --git a/Movies/Movies/Client/LiveClient.swift b/Movies/Movies/Client/LiveClient.swift index 0fdc3bc1..8aaa67b8 100644 --- a/Movies/Movies/Client/LiveClient.swift +++ b/Movies/Movies/Client/LiveClient.swift @@ -4,12 +4,21 @@ import Combine /// The `LiveClient` that uses https://www.themoviedb.org/ /// -/// Remember to set the`key` property to your api key, see: https://developers.themoviedb.org/3/getting-started/authentication +/// Remember to set the`key` property to your api key, see: +/// https://developers.themoviedb.org/3/getting-started/authentication class LiveClient: Client { #warning ("Remove/Insert API Secret") let key = "" let session = URLSession.shared + /// Fetch top movies + /// - Parameters: + /// - sorting: The movie sorting + /// + /// - Returns: A `Publisher` of `[Movies]` + /// + /// API information: + /// https://developers.themoviedb.org/3/movies/get-popular-movies func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> { // https://api.themoviedb.org/3/movie/popular?api_key=<>&language=en-US&page=1 let url = URL(string: """ @@ -29,7 +38,7 @@ class LiveClient: Client { return element.data } .decode(type: ListResponse.self, decoder: JSONDecoder.convertFromSnakeCase) - .map { $0.results.map { $0.movie } } + .map { $0.results.compactMap { $0.movie } } .mapError { error -> Error in switch error { case let error as Swift.DecodingError: @@ -43,8 +52,56 @@ class LiveClient: Client { .receive(on: RunLoop.main) .eraseToAnyPublisher() } + + /// Fetch a movie poster image + /// - Parameters: + /// - posterPath: The path to the poster + /// - size: the image size + /// + /// - Returns: A publisher of UIImage + /// + /// This uses the hardcoded url discussed here: + /// https://www.themoviedb.org/talk/5aeaaf56c3a3682ddf0010de + /// + /// A production release should use api-configuration instead: + /// https://developers.themoviedb.org/3/configuration/get-api-configuration + func poster(posterPath: String, size: ImageSize) -> AnyPublisher { + let url = URL(string: """ + https://image.tmdb.org/t/p/\(size.sizeString)/\(posterPath) + """)! + return session + .dataTaskPublisher(for: url) + .tryMap() { element -> Data in + guard + let httpResponse = element.response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { throw URLError(.badServerResponse) } + return element.data + } + .tryMap { data -> UIImage in + guard + let image = UIImage(data: data) + else { throw Error.decodingImage } + return image + } + .mapError { error -> Error in + switch error { + case let error as Swift.DecodingError: + return .decoding(error) + case let error as URLError: + return .url(error) + case let error as Error: + return error + default: + return .other(error) + } + } + .receive(on: RunLoop.main) + .eraseToAnyPublisher() + } } + extension LiveClient { /// An intermediate struct for converting a API JSON response into `Movie`. struct ListResponse: Codable { @@ -54,15 +111,24 @@ extension LiveClient { var voteAverage: Double var popularity: Double var releaseDate: String - + var posterPath: String? + /// Maps a `MovieResponse` to a `Movie`. - var movie: Movie { - .init( + var movie: Movie? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + guard + let date = dateFormatter.date(from:releaseDate) + else { return nil } + + return Movie( id: id, title: title, rating: voteAverage, popularity: popularity, - releaseDate: releaseDate + releaseDate: date, + posterPath: posterPath ) } } @@ -85,6 +151,18 @@ fileprivate extension MovieSorting { } } +fileprivate extension ImageSize { + /// Translates a `ImageSize` to the corresponding size string used by the endpoint. + var sizeString: String { + switch self { + case .thumbnail: + return "w342" + case .full: + return "original" + } + } +} + fileprivate extension JSONDecoder { /// A `JSONDecoder` configured to automatically converts from snake_case to camelCase. static var convertFromSnakeCase: JSONDecoder { diff --git a/Movies/Movies/Models/Error.swift b/Movies/Movies/Models/Error.swift index d6e842e8..90a48109 100644 --- a/Movies/Movies/Models/Error.swift +++ b/Movies/Movies/Models/Error.swift @@ -5,5 +5,6 @@ enum Error: Swift.Error { case other(Swift.Error) case decoding(DecodingError) case url(URLError) + case decodingImage } diff --git a/Movies/Movies/Models/ImageSize.swift b/Movies/Movies/Models/ImageSize.swift new file mode 100644 index 00000000..30328af6 --- /dev/null +++ b/Movies/Movies/Models/ImageSize.swift @@ -0,0 +1,6 @@ +import Foundation + +enum ImageSize { + case thumbnail + case full +} diff --git a/Movies/Movies/Models/Movie.swift b/Movies/Movies/Models/Movie.swift index d46e7b53..01b0ecc3 100644 --- a/Movies/Movies/Models/Movie.swift +++ b/Movies/Movies/Models/Movie.swift @@ -5,5 +5,6 @@ struct Movie: Identifiable { var title: String var rating: Double var popularity: Double - var releaseDate: String + var releaseDate: Date + var posterPath: String? } diff --git a/Movies/Movies/Models/MovieSorting.swift b/Movies/Movies/Models/MovieSorting.swift index b8b011ab..11664c64 100644 --- a/Movies/Movies/Models/MovieSorting.swift +++ b/Movies/Movies/Models/MovieSorting.swift @@ -1,8 +1,8 @@ import Foundation enum MovieSorting: String, CaseIterable, Identifiable { - case popularity case rating + case popularity var id: String { self.rawValue } } diff --git a/Movies/Movies/SceneDelegate.swift b/Movies/Movies/SceneDelegate.swift index 2b82ccf9..a50d61b9 100644 --- a/Movies/Movies/SceneDelegate.swift +++ b/Movies/Movies/SceneDelegate.swift @@ -9,8 +9,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { - let view = MovieListView(viewModel: .init(client: LiveClient())) + let view = NavigationView { + MovieListView(viewModel: .init(client: LiveClient())) + .navigationBarTitle("Top Movies", displayMode: .inline) + } + if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: view) diff --git a/Movies/Movies/UI/MovieListRow.swift b/Movies/Movies/UI/MovieListRow.swift new file mode 100644 index 00000000..00285209 --- /dev/null +++ b/Movies/Movies/UI/MovieListRow.swift @@ -0,0 +1,76 @@ +import SwiftUI +import Combine + +class MovieListRowViewModel: ObservableObject { + let client: Client + let movie: Movie + @Published var poster: UIImage? + @Published var hasFailed: Error? + + private var cancellables = Set() + + init(client: Client, movie: Movie) { + self.client = client + self.movie = movie + + if let posterPath = movie.posterPath { + client + .poster(posterPath: posterPath, size: .thumbnail) + .map { Optional($0) } + .replaceError(with: nil) + .assign(to: \.poster, on: self) + .store(in: &cancellables) + } + } +} + +struct MovieListRow: View { + @ObservedObject var viewModel: MovieListRowViewModel + var body: some View { + HStack { + Image(uiImage: viewModel.poster ?? UIImage()) + .resizable() + .aspectRatio(342.0 / 513.0, contentMode: .fit) + VStack(alignment: .leading) { + Text(viewModel.movie.title) + .font(.headline) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + detailRow(movie: viewModel.movie) + } + } + } + + func detailRow(movie: Movie) -> some View { + let format = DateFormatter() + format.dateFormat = "yyyy" + + return HStack { + Image(systemName: "star.fill") + .foregroundColor(Color.yellow) + Text(String(format: "%.1f", movie.rating)) + .fontWeight(.bold) + Text(format.string(from: movie.releaseDate)) + } + .fixedSize(horizontal: true, vertical: true) + .font(.caption) + + } +} + +struct MovieListRow_Previews: PreviewProvider { + static var previews: some View { + MovieListRow(viewModel: .init( + client: LiveClient(), + movie: .init( + id: 0, + //title: "Testing a very long title, like extremely super duper long!", + title: "Short", + rating: 5.0, + popularity: 6.0, + releaseDate: Date(), + posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg")) + ) + .frame(height: 100) + } +} diff --git a/Movies/Movies/UI/MovieListView.swift b/Movies/Movies/UI/MovieListView.swift index 575ee31a..8ccedd01 100644 --- a/Movies/Movies/UI/MovieListView.swift +++ b/Movies/Movies/UI/MovieListView.swift @@ -26,7 +26,6 @@ class MovieListViewModel: ObservableObject { }, receiveValue: { self.movies = $0 } ) .store(in: &cancellables) - } } @@ -44,15 +43,22 @@ struct MovieListView: View { .padding()) { ForEach(viewModel.movies) { movie in - Text("\(movie.title)") + NavigationLink(destination: Text("Detail \(movie.id)")) { + MovieListRow(viewModel: .init(client: self.viewModel.client, movie: movie)) + .frame(height: 120) + } } } } + .listStyle(GroupedListStyle()) } } struct MovieListView_Previews: PreviewProvider { static var previews: some View { - MovieListView(viewModel: .init(client: LiveClient())) + NavigationView { + MovieListView(viewModel: .init(client: LiveClient())) + .navigationBarTitle("Top Movies", displayMode: .inline) + } } } diff --git a/Movies/MoviesTests/ClientTests.swift b/Movies/MoviesTests/ClientTests.swift index 9a56a655..321c67ca 100644 --- a/Movies/MoviesTests/ClientTests.swift +++ b/Movies/MoviesTests/ClientTests.swift @@ -31,4 +31,30 @@ class ClientTests: XCTestCase { wait(for: [expectation], timeout: 1) } + + func testLiveClientImage() throws { + // The image url, posterPath is the last component. + // https://image.tmdb.org/t/p/w342/bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg + let client = LiveClient() + let expectation = self.expectation(description: "receive value") + client + .poster(posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", size: .thumbnail) + .sink( + receiveCompletion: { + switch $0 { + case .failure(let error): + XCTFail("\(error)") + case .finished: + break + } + }, + receiveValue: { value in + print(value.size) + expectation.fulfill() + }) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + } From d6b4e1ab17efa80c7bf7e7231c5a91306b57e2fe Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin <36726279+gjorlin@users.noreply.github.com> Date: Sat, 12 Sep 2020 17:00:02 +0200 Subject: [PATCH 6/7] Feature/movie detail (#5) * added api secret * added movie detail view * removed api secret --- Movies/Movies.xcodeproj/project.pbxproj | 56 ++++++++++--- Movies/Movies/AppDelegate.swift | 1 + Movies/Movies/Client/Client.swift | 2 +- Movies/Movies/Client/LiveClient.swift | 66 ++++++++------- Movies/Movies/Extensions/Date.swift | 10 +++ Movies/Movies/Info.plist | 2 - .../Models/{ImageSize.swift => Image.swift} | 5 ++ Movies/Movies/Models/Movie.swift | 2 + Movies/Movies/SceneDelegate.swift | 6 +- .../UI/MovieDetail/MovieDetailView.swift | 81 +++++++++++++++++++ .../UI/{ => MovieList}/MovieListRow.swift | 42 +++++----- .../UI/{ => MovieList}/MovieListView.swift | 15 +++- Movies/Movies/UI/TopMoviesView.swift | 13 +++ Movies/MoviesTests/ClientTests.swift | 6 +- 14 files changed, 234 insertions(+), 73 deletions(-) create mode 100644 Movies/Movies/Extensions/Date.swift rename Movies/Movies/Models/{ImageSize.swift => Image.swift} (57%) create mode 100644 Movies/Movies/UI/MovieDetail/MovieDetailView.swift rename Movies/Movies/UI/{ => MovieList}/MovieListRow.swift (63%) rename Movies/Movies/UI/{ => MovieList}/MovieListView.swift (79%) create mode 100644 Movies/Movies/UI/TopMoviesView.swift diff --git a/Movies/Movies.xcodeproj/project.pbxproj b/Movies/Movies.xcodeproj/project.pbxproj index 517791fc..c563b98a 100644 --- a/Movies/Movies.xcodeproj/project.pbxproj +++ b/Movies/Movies.xcodeproj/project.pbxproj @@ -25,9 +25,12 @@ AB4AA192250C7FF4007654B8 /* MovieSorting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */; }; AB4AA193250C7FF4007654B8 /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA190250C7FC3007654B8 /* Error.swift */; }; AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA195250C97CF007654B8 /* MovieListView.swift */; }; - AB91ACB8250CB43E00262B07 /* ImageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACB7250CB43E00262B07 /* ImageSize.swift */; }; - AB91ACB9250CB7A500262B07 /* ImageSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACB7250CB43E00262B07 /* ImageSize.swift */; }; + AB6C8995250D14A5002974BB /* TopMoviesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB6C8994250D14A5002974BB /* TopMoviesView.swift */; }; + AB91ACB8250CB43E00262B07 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACB7250CB43E00262B07 /* Image.swift */; }; + AB91ACB9250CB7A500262B07 /* Image.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACB7250CB43E00262B07 /* Image.swift */; }; AB91ACBB250CBD6800262B07 /* MovieListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACBA250CBD6800262B07 /* MovieListRow.swift */; }; + AB91ACBF250CE36E00262B07 /* MovieDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACBE250CE36E00262B07 /* MovieDetailView.swift */; }; + AB91ACC2250CE71800262B07 /* Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB91ACC1250CE71800262B07 /* Date.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -57,8 +60,11 @@ AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieSorting.swift; sourceTree = ""; }; AB4AA190250C7FC3007654B8 /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; AB4AA195250C97CF007654B8 /* MovieListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListView.swift; sourceTree = ""; }; - AB91ACB7250CB43E00262B07 /* ImageSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageSize.swift; sourceTree = ""; }; + AB6C8994250D14A5002974BB /* TopMoviesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopMoviesView.swift; sourceTree = ""; }; + AB91ACB7250CB43E00262B07 /* Image.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Image.swift; sourceTree = ""; }; AB91ACBA250CBD6800262B07 /* MovieListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListRow.swift; sourceTree = ""; }; + AB91ACBE250CE36E00262B07 /* MovieDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieDetailView.swift; sourceTree = ""; }; + AB91ACC1250CE71800262B07 /* Date.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Date.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -100,6 +106,7 @@ AB4AA15C250BC674007654B8 /* Movies */ = { isa = PBXGroup; children = ( + AB91ACC0250CE70A00262B07 /* Extensions */, AB4AA18A250BF309007654B8 /* Models */, AB4AA183250BE52E007654B8 /* Client */, AB4AA194250C97A5007654B8 /* UI */, @@ -145,18 +152,44 @@ AB4AA18B250BF314007654B8 /* Movie.swift */, AB4AA18E250C7FB9007654B8 /* MovieSorting.swift */, AB4AA190250C7FC3007654B8 /* Error.swift */, - AB91ACB7250CB43E00262B07 /* ImageSize.swift */, + AB91ACB7250CB43E00262B07 /* Image.swift */, ); path = Models; sourceTree = ""; }; AB4AA194250C97A5007654B8 /* UI */ = { + isa = PBXGroup; + children = ( + AB6C8994250D14A5002974BB /* TopMoviesView.swift */, + AB91ACBC250CE33B00262B07 /* MovieList */, + AB91ACBD250CE34C00262B07 /* MovieDetail */, + ); + path = UI; + sourceTree = ""; + }; + AB91ACBC250CE33B00262B07 /* MovieList */ = { isa = PBXGroup; children = ( AB4AA195250C97CF007654B8 /* MovieListView.swift */, AB91ACBA250CBD6800262B07 /* MovieListRow.swift */, ); - path = UI; + path = MovieList; + sourceTree = ""; + }; + AB91ACBD250CE34C00262B07 /* MovieDetail */ = { + isa = PBXGroup; + children = ( + AB91ACBE250CE36E00262B07 /* MovieDetailView.swift */, + ); + path = MovieDetail; + sourceTree = ""; + }; + AB91ACC0250CE70A00262B07 /* Extensions */ = { + isa = PBXGroup; + children = ( + AB91ACC1250CE71800262B07 /* Date.swift */, + ); + path = Extensions; sourceTree = ""; }; /* End PBXGroup section */ @@ -259,16 +292,19 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AB6C8995250D14A5002974BB /* TopMoviesView.swift in Sources */, AB4AA191250C7FC3007654B8 /* Error.swift in Sources */, AB4AA15E250BC674007654B8 /* AppDelegate.swift in Sources */, AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */, AB4AA18F250C7FB9007654B8 /* MovieSorting.swift in Sources */, - AB91ACB8250CB43E00262B07 /* ImageSize.swift in Sources */, + AB91ACB8250CB43E00262B07 /* Image.swift in Sources */, AB91ACBB250CBD6800262B07 /* MovieListRow.swift in Sources */, AB4AA160250BC674007654B8 /* SceneDelegate.swift in Sources */, + AB91ACC2250CE71800262B07 /* Date.swift in Sources */, AB4AA18C250BF314007654B8 /* Movie.swift in Sources */, AB4AA185250BE53D007654B8 /* Client.swift in Sources */, AB4AA187250BE649007654B8 /* LiveClient.swift in Sources */, + AB91ACBF250CE36E00262B07 /* MovieDetailView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -276,7 +312,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AB91ACB9250CB7A500262B07 /* ImageSize.swift in Sources */, + AB91ACB9250CB7A500262B07 /* Image.swift in Sources */, AB4AA182250BE500007654B8 /* AppDelegate.swift in Sources */, AB4AA180250BD795007654B8 /* ClientTests.swift in Sources */, AB4AA18D250BF8AE007654B8 /* Movie.swift in Sources */, @@ -432,6 +468,7 @@ DEVELOPMENT_TEAM = 88S3B6XZEL; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Movies/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -439,7 +476,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.bitcycle.Movies; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -452,6 +489,7 @@ DEVELOPMENT_TEAM = 88S3B6XZEL; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Movies/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -459,7 +497,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.bitcycle.Movies; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; diff --git a/Movies/Movies/AppDelegate.swift b/Movies/Movies/AppDelegate.swift index e89e08f7..2e2fb3dc 100644 --- a/Movies/Movies/AppDelegate.swift +++ b/Movies/Movies/AppDelegate.swift @@ -1,5 +1,6 @@ import UIKit + @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { diff --git a/Movies/Movies/Client/Client.swift b/Movies/Movies/Client/Client.swift index 0c98ace2..f9976e96 100644 --- a/Movies/Movies/Client/Client.swift +++ b/Movies/Movies/Client/Client.swift @@ -4,5 +4,5 @@ import Combine /// The interface for the movie API. protocol Client { func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> - func poster(posterPath: String, size: ImageSize) -> AnyPublisher + func image(path: String, type: ImageType, size: ImageSize) -> AnyPublisher } diff --git a/Movies/Movies/Client/LiveClient.swift b/Movies/Movies/Client/LiveClient.swift index 8aaa67b8..f7e85806 100644 --- a/Movies/Movies/Client/LiveClient.swift +++ b/Movies/Movies/Client/LiveClient.swift @@ -49,7 +49,7 @@ class LiveClient: Client { return .other(error) } } - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } @@ -65,9 +65,9 @@ class LiveClient: Client { /// /// A production release should use api-configuration instead: /// https://developers.themoviedb.org/3/configuration/get-api-configuration - func poster(posterPath: String, size: ImageSize) -> AnyPublisher { + func image(path: String, type: ImageType, size: ImageSize) -> AnyPublisher { let url = URL(string: """ - https://image.tmdb.org/t/p/\(size.sizeString)/\(posterPath) + https://image.tmdb.org/t/p/\(sizeString(type: type, size: size))/\(path) """)! return session .dataTaskPublisher(for: url) @@ -96,7 +96,7 @@ class LiveClient: Client { return .other(error) } } - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } } @@ -112,25 +112,8 @@ extension LiveClient { var popularity: Double var releaseDate: String var posterPath: String? - - /// Maps a `MovieResponse` to a `Movie`. - var movie: Movie? { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd" - - guard - let date = dateFormatter.date(from:releaseDate) - else { return nil } - - return Movie( - id: id, - title: title, - rating: voteAverage, - popularity: popularity, - releaseDate: date, - posterPath: posterPath - ) - } + var backdropPath: String? + var overview: String } var page: Int var totalResults: Int @@ -139,6 +122,29 @@ extension LiveClient { } } +extension LiveClient.ListResponse.MovieResponse { + /// Maps a `MovieResponse` to a `Movie`. + var movie: Movie? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + guard + let date = dateFormatter.date(from:releaseDate) + else { return nil } + + return Movie( + id: id, + title: title, + rating: voteAverage, + popularity: popularity, + releaseDate: date, + posterPath: posterPath, + backdropPath: backdropPath, + overview: overview + ) + } +} + fileprivate extension MovieSorting { /// Translates a `MovieSorting` to the corresponding query string used by the endpoint. var queryString: String { @@ -151,13 +157,17 @@ fileprivate extension MovieSorting { } } -fileprivate extension ImageSize { +fileprivate extension LiveClient { /// Translates a `ImageSize` to the corresponding size string used by the endpoint. - var sizeString: String { - switch self { - case .thumbnail: + func sizeString(type: ImageType, size: ImageSize) -> String { + switch (type,size) { + case (.poster, .thumbnail): return "w342" - case .full: + case (.poster, .full): + return "original" + case (.backdrop, .thumbnail): + return "w300" + case (.backdrop, .full): return "original" } } diff --git a/Movies/Movies/Extensions/Date.swift b/Movies/Movies/Extensions/Date.swift new file mode 100644 index 00000000..060133e3 --- /dev/null +++ b/Movies/Movies/Extensions/Date.swift @@ -0,0 +1,10 @@ +import Foundation + +extension Date { + var year: String { + let format = DateFormatter() + format.dateFormat = "yyyy" + return format.string(from: self) + } +} + diff --git a/Movies/Movies/Info.plist b/Movies/Movies/Info.plist index 9742bf0f..cb967d41 100644 --- a/Movies/Movies/Info.plist +++ b/Movies/Movies/Info.plist @@ -46,8 +46,6 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad diff --git a/Movies/Movies/Models/ImageSize.swift b/Movies/Movies/Models/Image.swift similarity index 57% rename from Movies/Movies/Models/ImageSize.swift rename to Movies/Movies/Models/Image.swift index 30328af6..cb8b5213 100644 --- a/Movies/Movies/Models/ImageSize.swift +++ b/Movies/Movies/Models/Image.swift @@ -4,3 +4,8 @@ enum ImageSize { case thumbnail case full } + +enum ImageType { + case poster + case backdrop +} diff --git a/Movies/Movies/Models/Movie.swift b/Movies/Movies/Models/Movie.swift index 01b0ecc3..2207029c 100644 --- a/Movies/Movies/Models/Movie.swift +++ b/Movies/Movies/Models/Movie.swift @@ -7,4 +7,6 @@ struct Movie: Identifiable { var popularity: Double var releaseDate: Date var posterPath: String? + var backdropPath: String? + var overview: String } diff --git a/Movies/Movies/SceneDelegate.swift b/Movies/Movies/SceneDelegate.swift index a50d61b9..fd980101 100644 --- a/Movies/Movies/SceneDelegate.swift +++ b/Movies/Movies/SceneDelegate.swift @@ -1,6 +1,7 @@ import SwiftUI import UIKit + class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? @@ -10,10 +11,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { options connectionOptions: UIScene.ConnectionOptions ) { - let view = NavigationView { - MovieListView(viewModel: .init(client: LiveClient())) - .navigationBarTitle("Top Movies", displayMode: .inline) - } + let view = TopMoviesView(client: LiveClient()) if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) diff --git a/Movies/Movies/UI/MovieDetail/MovieDetailView.swift b/Movies/Movies/UI/MovieDetail/MovieDetailView.swift new file mode 100644 index 00000000..304d537f --- /dev/null +++ b/Movies/Movies/UI/MovieDetail/MovieDetailView.swift @@ -0,0 +1,81 @@ +import SwiftUI +import Combine + + +class MovieDetailViewModel: ObservableObject { + let client: Client + let movie: Movie + @Published var backdrop: UIImage? + var movieRating: String { String(format: "%.1f", movie.rating) } + + private var cancellables = Set() + + init(client: Client, movie: Movie) { + self.client = client + self.movie = movie + + if let backdropPath = movie.backdropPath { + client + .image(path: backdropPath, type: .backdrop, size: .thumbnail) + .map { Optional($0) } + .replaceError(with: nil) + .assign(to: \.backdrop, on: self) + .store(in: &cancellables) + } + } +} + +struct MovieDetailView: View { + @ObservedObject var viewModel: MovieDetailViewModel + var body: some View { + ScrollView { + VStack(alignment: .leading) { + Text(viewModel.movie.title) + .fixedSize(horizontal: false, vertical: true) + .font(.title) + HStack(alignment: .firstTextBaseline) { + Text(viewModel.movie.releaseDate.year) + .padding([.trailing]) + Image(systemName: "star.fill") + .foregroundColor(Color.yellow) + Text(viewModel.movieRating) + } + .font(.body) + .fixedSize(horizontal: true, vertical: true) + + Image(uiImage: viewModel.backdrop ?? UIImage()) + .resizable() + .aspectRatio(450.0/300.0, contentMode: .fit) + .padding([.bottom]) + Text(viewModel.movie.overview) + .font(.body) + Spacer() + } + } + .padding() + .navigationBarTitle("\(viewModel.movie.title)", displayMode: .inline) + } +} + +struct MovieDetailView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + MovieDetailView(viewModel: .init( + client: LiveClient(), + movie: Movie( + id: 0, + title: "Testing a very long title, like extremely super duper long!", + //title: "Short", + rating: 5.0, + popularity: 6.0, + releaseDate: Date(), + posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", + backdropPath: "bOGkgRGdhrBYJSLpXaxhXVstddV.jpg", + overview: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + """) + ) + ) + } + } +} diff --git a/Movies/Movies/UI/MovieListRow.swift b/Movies/Movies/UI/MovieList/MovieListRow.swift similarity index 63% rename from Movies/Movies/UI/MovieListRow.swift rename to Movies/Movies/UI/MovieList/MovieListRow.swift index 00285209..daae227c 100644 --- a/Movies/Movies/UI/MovieListRow.swift +++ b/Movies/Movies/UI/MovieList/MovieListRow.swift @@ -1,12 +1,14 @@ import SwiftUI import Combine + class MovieListRowViewModel: ObservableObject { let client: Client let movie: Movie @Published var poster: UIImage? @Published var hasFailed: Error? - + var movieRating: String { String(format: "%.1f", movie.rating) } + private var cancellables = Set() init(client: Client, movie: Movie) { @@ -15,7 +17,7 @@ class MovieListRowViewModel: ObservableObject { if let posterPath = movie.posterPath { client - .poster(posterPath: posterPath, size: .thumbnail) + .image(path: posterPath, type: .poster, size: .thumbnail) .map { Optional($0) } .replaceError(with: nil) .assign(to: \.poster, on: self) @@ -31,31 +33,25 @@ struct MovieListRow: View { Image(uiImage: viewModel.poster ?? UIImage()) .resizable() .aspectRatio(342.0 / 513.0, contentMode: .fit) + .frame(height: 120) VStack(alignment: .leading) { Text(viewModel.movie.title) .font(.headline) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) - detailRow(movie: viewModel.movie) + HStack { + Image(systemName: "star.fill") + .foregroundColor(Color.yellow) + Text(viewModel.movieRating) + .fontWeight(.bold) + Text(viewModel.movie.releaseDate.year) + } + .fixedSize(horizontal: true, vertical: true) + .font(.caption) } + Spacer() } } - - func detailRow(movie: Movie) -> some View { - let format = DateFormatter() - format.dateFormat = "yyyy" - - return HStack { - Image(systemName: "star.fill") - .foregroundColor(Color.yellow) - Text(String(format: "%.1f", movie.rating)) - .fontWeight(.bold) - Text(format.string(from: movie.releaseDate)) - } - .fixedSize(horizontal: true, vertical: true) - .font(.caption) - - } } struct MovieListRow_Previews: PreviewProvider { @@ -64,13 +60,13 @@ struct MovieListRow_Previews: PreviewProvider { client: LiveClient(), movie: .init( id: 0, - //title: "Testing a very long title, like extremely super duper long!", - title: "Short", + title: "Testing a very long title, like extremely super duper long!", + //title: "Short one two", rating: 5.0, popularity: 6.0, releaseDate: Date(), - posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg")) + posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", + overview: "overview")) ) - .frame(height: 100) } } diff --git a/Movies/Movies/UI/MovieListView.swift b/Movies/Movies/UI/MovieList/MovieListView.swift similarity index 79% rename from Movies/Movies/UI/MovieListView.swift rename to Movies/Movies/UI/MovieList/MovieListView.swift index 8ccedd01..349ede94 100644 --- a/Movies/Movies/UI/MovieListView.swift +++ b/Movies/Movies/UI/MovieList/MovieListView.swift @@ -1,6 +1,7 @@ import SwiftUI import Combine + class MovieListViewModel: ObservableObject { let client: Client @Published var movieSorting: MovieSorting = .rating @@ -43,15 +44,23 @@ struct MovieListView: View { .padding()) { ForEach(viewModel.movies) { movie in - NavigationLink(destination: Text("Detail \(movie.id)")) { - MovieListRow(viewModel: .init(client: self.viewModel.client, movie: movie)) - .frame(height: 120) + NavigationLink( + destination: self.detailView(movie: movie)) { + self.movieListRow(movie: movie) } } } } .listStyle(GroupedListStyle()) } + + func detailView(movie: Movie) -> some View { + MovieDetailView(viewModel: .init(client: self.viewModel.client, movie: movie)) + } + + func movieListRow(movie: Movie) -> some View { + MovieListRow(viewModel: .init(client: self.viewModel.client, movie: movie)) + } } struct MovieListView_Previews: PreviewProvider { diff --git a/Movies/Movies/UI/TopMoviesView.swift b/Movies/Movies/UI/TopMoviesView.swift new file mode 100644 index 00000000..9f55f399 --- /dev/null +++ b/Movies/Movies/UI/TopMoviesView.swift @@ -0,0 +1,13 @@ +import SwiftUI + + +struct TopMoviesView: View { + let client: Client + var body: some View { + NavigationView { + MovieListView(viewModel: .init(client: client)) + .navigationBarTitle("Top Movies", displayMode: .inline) + } + .accentColor(Color.yellow) + } +} diff --git a/Movies/MoviesTests/ClientTests.swift b/Movies/MoviesTests/ClientTests.swift index 321c67ca..155017ba 100644 --- a/Movies/MoviesTests/ClientTests.swift +++ b/Movies/MoviesTests/ClientTests.swift @@ -24,7 +24,6 @@ class ClientTests: XCTestCase { } }, receiveValue: { value in - print(value.count) expectation.fulfill() }) .store(in: &cancellables) @@ -35,10 +34,12 @@ class ClientTests: XCTestCase { func testLiveClientImage() throws { // The image url, posterPath is the last component. // https://image.tmdb.org/t/p/w342/bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg + //let path = "ndlQ2Cuc3cjTL7lTynw6I4boP4S.jpg" + let path = "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg" let client = LiveClient() let expectation = self.expectation(description: "receive value") client - .poster(posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", size: .thumbnail) + .image(path: path, type: .backdrop, size: .thumbnail) .sink( receiveCompletion: { switch $0 { @@ -49,7 +50,6 @@ class ClientTests: XCTestCase { } }, receiveValue: { value in - print(value.size) expectation.fulfill() }) .store(in: &cancellables) From 67429a294e0de15fb804a0c93e6eff334fa8757f Mon Sep 17 00:00:00 2001 From: Gustaf Jorlin <36726279+gjorlin@users.noreply.github.com> Date: Sat, 12 Sep 2020 20:40:49 +0200 Subject: [PATCH 7/7] Cleanup and added notes to README. (#6) * swift-format * Added docs and updated README. --- Movies/Movies/AppDelegate.swift | 1 - Movies/Movies/Client/Client.swift | 20 ++- Movies/Movies/Client/LiveClient.swift | 166 +++++++++--------- Movies/Movies/Extensions/Date.swift | 2 +- Movies/Movies/Models/Error.swift | 2 - Movies/Movies/Models/MovieSorting.swift | 1 - Movies/Movies/SceneDelegate.swift | 2 +- .../UI/MovieDetail/MovieDetailView.swift | 40 ++--- Movies/Movies/UI/MovieList/MovieListRow.swift | 29 +-- .../Movies/UI/MovieList/MovieListView.swift | 37 ++-- Movies/Movies/UI/TopMoviesView.swift | 6 + Movies/MoviesTests/ClientTests.swift | 23 +-- README.md | 52 ++---- 13 files changed, 198 insertions(+), 183 deletions(-) diff --git a/Movies/Movies/AppDelegate.swift b/Movies/Movies/AppDelegate.swift index 2e2fb3dc..8941c103 100644 --- a/Movies/Movies/AppDelegate.swift +++ b/Movies/Movies/AppDelegate.swift @@ -3,7 +3,6 @@ import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? diff --git a/Movies/Movies/Client/Client.swift b/Movies/Movies/Client/Client.swift index f9976e96..a8e96728 100644 --- a/Movies/Movies/Client/Client.swift +++ b/Movies/Movies/Client/Client.swift @@ -1,8 +1,22 @@ -import UIKit import Combine +import UIKit + /// The interface for the movie API. protocol Client { - func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> - func image(path: String, type: ImageType, size: ImageSize) -> AnyPublisher + + /// Fetches movies sorted by `MovieSorting`. + /// + /// - Parameter sorting: The sorting of the returned movies. + /// - Returns: A publisher of movies. + func movies(sorting: MovieSorting) -> AnyPublisher<[Movie], Error> + + /// Fetch an image movie related image. + /// + /// - Parameters: + /// - path: The path to image. + /// - type: The image type. + /// - size: The image size. + /// - Returns: A publisher of an image. + func image(path: String, type: ImageType, size: ImageSize) -> AnyPublisher } diff --git a/Movies/Movies/Client/LiveClient.swift b/Movies/Movies/Client/LiveClient.swift index f7e85806..54f26e34 100644 --- a/Movies/Movies/Client/LiveClient.swift +++ b/Movies/Movies/Client/LiveClient.swift @@ -1,109 +1,115 @@ -import UIKit import Combine +import UIKit /// The `LiveClient` that uses https://www.themoviedb.org/ /// -/// Remember to set the`key` property to your api key, see: +/// Remember to set the `key` property to your api key, see: /// https://developers.themoviedb.org/3/getting-started/authentication +/// +/// And also, don't us use key secrets in apps ;) +/// https://nshipster.com/secrets/ class LiveClient: Client { - #warning ("Remove/Insert API Secret") + #warning("Remove/Insert API Secret") let key = "" let session = URLSession.shared - - /// Fetch top movies - /// - Parameters: - /// - sorting: The movie sorting + + /// Fetches movies sorted by `MovieSorting`. /// - /// - Returns: A `Publisher` of `[Movies]` + /// - Parameter sorting: The sorting of the returned movies. + /// - Returns: A publisher of movies. /// /// API information: /// https://developers.themoviedb.org/3/movies/get-popular-movies - func movies(sorting: MovieSorting) -> AnyPublisher<[Movie],Error> { + func movies(sorting: MovieSorting) -> AnyPublisher<[Movie], Error> { // https://api.themoviedb.org/3/movie/popular?api_key=<>&language=en-US&page=1 - let url = URL(string: """ - https://api.themoviedb.org/3/movie/\ - \(sorting.queryString)?\ - api_key=\(key)\ - &language=en-US\ - &page=1 - """)! - return session - .dataTaskPublisher(for: url) - .tryMap() { element -> Data in + let url = URL( + string: """ + https://api.themoviedb.org/3/movie/\ + \(sorting.queryString)?\ + api_key=\(key)\ + &language=en-US\ + &page=1 + """)! + return + session + .dataTaskPublisher(for: url) + .tryMap { element -> Data in guard let httpResponse = element.response as? HTTPURLResponse, httpResponse.statusCode == 200 - else { throw URLError(.badServerResponse) } + else { throw URLError(.badServerResponse) } return element.data - } - .decode(type: ListResponse.self, decoder: JSONDecoder.convertFromSnakeCase) - .map { $0.results.compactMap { $0.movie } } - .mapError { error -> Error in - switch error { - case let error as Swift.DecodingError: - return .decoding(error) - case let error as URLError: - return .url(error) - default: - return .other(error) } - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + .decode(type: ListResponse.self, decoder: JSONDecoder.convertFromSnakeCase) + .map { $0.results.compactMap { $0.movie } } + .mapError { error -> Error in + switch error { + case let error as Swift.DecodingError: + return .decoding(error) + case let error as URLError: + return .url(error) + default: + return .other(error) + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() } - - /// Fetch a movie poster image - /// - Parameters: - /// - posterPath: The path to the poster - /// - size: the image size + + /// Fetch an image movie related image. /// - /// - Returns: A publisher of UIImage + /// - Parameters: + /// - path: The path to image. + /// - type: The image type. + /// - size: The image size. + /// - Returns: A publisher of an image. /// /// This uses the hardcoded url discussed here: /// https://www.themoviedb.org/talk/5aeaaf56c3a3682ddf0010de /// - /// A production release should use api-configuration instead: + /// TODO: A production release should instead use api-configuration: /// https://developers.themoviedb.org/3/configuration/get-api-configuration - func image(path: String, type: ImageType, size: ImageSize) -> AnyPublisher { - let url = URL(string: """ - https://image.tmdb.org/t/p/\(sizeString(type: type, size: size))/\(path) - """)! - return session + func image(path: String, type: ImageType, size: ImageSize) -> AnyPublisher { + let url = URL( + string: """ + https://image.tmdb.org/t/p/\(sizeString(type: type, size: size))/\(path) + """)! + return + session .dataTaskPublisher(for: url) - .tryMap() { element -> Data in + .tryMap { element -> Data in guard let httpResponse = element.response as? HTTPURLResponse, httpResponse.statusCode == 200 - else { throw URLError(.badServerResponse) } + else { throw URLError(.badServerResponse) } return element.data - } - .tryMap { data -> UIImage in - guard - let image = UIImage(data: data) + } + .tryMap { data -> UIImage in + guard + let image = UIImage(data: data) else { throw Error.decodingImage } - return image - } - .mapError { error -> Error in - switch error { - case let error as Swift.DecodingError: - return .decoding(error) - case let error as URLError: - return .url(error) - case let error as Error: - return error - default: - return .other(error) + return image } - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() + .mapError { error -> Error in + switch error { + case let error as Swift.DecodingError: + return .decoding(error) + case let error as URLError: + return .url(error) + case let error as Error: + return error + default: + return .other(error) + } + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() } } - extension LiveClient { - /// An intermediate struct for converting a API JSON response into `Movie`. + /// An intermediate struct for converting a API JSON response into `Movie` array. struct ListResponse: Codable { struct MovieResponse: Codable { var id: Int @@ -127,11 +133,11 @@ extension LiveClient.ListResponse.MovieResponse { var movie: Movie? { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd" - + guard - let date = dateFormatter.date(from:releaseDate) - else { return nil } - + let date = dateFormatter.date(from: releaseDate) + else { return nil } + return Movie( id: id, title: title, @@ -145,9 +151,9 @@ extension LiveClient.ListResponse.MovieResponse { } } -fileprivate extension MovieSorting { +extension MovieSorting { /// Translates a `MovieSorting` to the corresponding query string used by the endpoint. - var queryString: String { + fileprivate var queryString: String { switch self { case .popularity: return "popular" @@ -157,10 +163,10 @@ fileprivate extension MovieSorting { } } -fileprivate extension LiveClient { +extension LiveClient { /// Translates a `ImageSize` to the corresponding size string used by the endpoint. - func sizeString(type: ImageType, size: ImageSize) -> String { - switch (type,size) { + fileprivate func sizeString(type: ImageType, size: ImageSize) -> String { + switch (type, size) { case (.poster, .thumbnail): return "w342" case (.poster, .full): @@ -173,9 +179,9 @@ fileprivate extension LiveClient { } } -fileprivate extension JSONDecoder { +extension JSONDecoder { /// A `JSONDecoder` configured to automatically converts from snake_case to camelCase. - static var convertFromSnakeCase: JSONDecoder { + fileprivate static var convertFromSnakeCase: JSONDecoder { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase return decoder diff --git a/Movies/Movies/Extensions/Date.swift b/Movies/Movies/Extensions/Date.swift index 060133e3..b5cb380c 100644 --- a/Movies/Movies/Extensions/Date.swift +++ b/Movies/Movies/Extensions/Date.swift @@ -1,10 +1,10 @@ import Foundation extension Date { + /// The year as a `String`. var year: String { let format = DateFormatter() format.dateFormat = "yyyy" return format.string(from: self) } } - diff --git a/Movies/Movies/Models/Error.swift b/Movies/Movies/Models/Error.swift index 90a48109..e1c13aef 100644 --- a/Movies/Movies/Models/Error.swift +++ b/Movies/Movies/Models/Error.swift @@ -1,10 +1,8 @@ import Foundation -/// The general error type used by the `Movies` application. enum Error: Swift.Error { case other(Swift.Error) case decoding(DecodingError) case url(URLError) case decodingImage } - diff --git a/Movies/Movies/Models/MovieSorting.swift b/Movies/Movies/Models/MovieSorting.swift index 11664c64..28b2478a 100644 --- a/Movies/Movies/Models/MovieSorting.swift +++ b/Movies/Movies/Models/MovieSorting.swift @@ -5,4 +5,3 @@ enum MovieSorting: String, CaseIterable, Identifiable { case popularity var id: String { self.rawValue } } - diff --git a/Movies/Movies/SceneDelegate.swift b/Movies/Movies/SceneDelegate.swift index fd980101..9f47eda6 100644 --- a/Movies/Movies/SceneDelegate.swift +++ b/Movies/Movies/SceneDelegate.swift @@ -12,7 +12,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { let view = TopMoviesView(client: LiveClient()) - + if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.rootViewController = UIHostingController(rootView: view) diff --git a/Movies/Movies/UI/MovieDetail/MovieDetailView.swift b/Movies/Movies/UI/MovieDetail/MovieDetailView.swift index 304d537f..4105fb08 100644 --- a/Movies/Movies/UI/MovieDetail/MovieDetailView.swift +++ b/Movies/Movies/UI/MovieDetail/MovieDetailView.swift @@ -1,6 +1,5 @@ -import SwiftUI import Combine - +import SwiftUI class MovieDetailViewModel: ObservableObject { let client: Client @@ -9,11 +8,11 @@ class MovieDetailViewModel: ObservableObject { var movieRating: String { String(format: "%.1f", movie.rating) } private var cancellables = Set() - + init(client: Client, movie: Movie) { self.client = client self.movie = movie - + if let backdropPath = movie.backdropPath { client .image(path: backdropPath, type: .backdrop, size: .thumbnail) @@ -42,10 +41,10 @@ struct MovieDetailView: View { } .font(.body) .fixedSize(horizontal: true, vertical: true) - + Image(uiImage: viewModel.backdrop ?? UIImage()) .resizable() - .aspectRatio(450.0/300.0, contentMode: .fit) + .aspectRatio(450.0 / 300.0, contentMode: .fit) .padding([.bottom]) Text(viewModel.movie.overview) .font(.body) @@ -60,20 +59,21 @@ struct MovieDetailView: View { struct MovieDetailView_Previews: PreviewProvider { static var previews: some View { NavigationView { - MovieDetailView(viewModel: .init( - client: LiveClient(), - movie: Movie( - id: 0, - title: "Testing a very long title, like extremely super duper long!", - //title: "Short", - rating: 5.0, - popularity: 6.0, - releaseDate: Date(), - posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", - backdropPath: "bOGkgRGdhrBYJSLpXaxhXVstddV.jpg", - overview: """ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. - """) + MovieDetailView( + viewModel: .init( + client: LiveClient(), + movie: Movie( + id: 0, + title: "Testing a very long title, like extremely super duper long!", + //title: "Short", + rating: 5.0, + popularity: 6.0, + releaseDate: Date(), + posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", + backdropPath: "bOGkgRGdhrBYJSLpXaxhXVstddV.jpg", + overview: """ + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + """) ) ) } diff --git a/Movies/Movies/UI/MovieList/MovieListRow.swift b/Movies/Movies/UI/MovieList/MovieListRow.swift index daae227c..7d07e749 100644 --- a/Movies/Movies/UI/MovieList/MovieListRow.swift +++ b/Movies/Movies/UI/MovieList/MovieListRow.swift @@ -1,5 +1,5 @@ -import SwiftUI import Combine +import SwiftUI class MovieListRowViewModel: ObservableObject { @@ -10,11 +10,11 @@ class MovieListRowViewModel: ObservableObject { var movieRating: String { String(format: "%.1f", movie.rating) } private var cancellables = Set() - + init(client: Client, movie: Movie) { self.client = client self.movie = movie - + if let posterPath = movie.posterPath { client .image(path: posterPath, type: .poster, size: .thumbnail) @@ -56,17 +56,18 @@ struct MovieListRow: View { struct MovieListRow_Previews: PreviewProvider { static var previews: some View { - MovieListRow(viewModel: .init( - client: LiveClient(), - movie: .init( - id: 0, - title: "Testing a very long title, like extremely super duper long!", - //title: "Short one two", - rating: 5.0, - popularity: 6.0, - releaseDate: Date(), - posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", - overview: "overview")) + MovieListRow( + viewModel: .init( + client: LiveClient(), + movie: .init( + id: 0, + title: "Testing a very long title, like extremely super duper long!", + //title: "Short", + rating: 5.0, + popularity: 6.0, + releaseDate: Date(), + posterPath: "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg", + overview: "overview")) ) } } diff --git a/Movies/Movies/UI/MovieList/MovieListView.swift b/Movies/Movies/UI/MovieList/MovieListView.swift index 349ede94..64a669ba 100644 --- a/Movies/Movies/UI/MovieList/MovieListView.swift +++ b/Movies/Movies/UI/MovieList/MovieListView.swift @@ -1,5 +1,5 @@ -import SwiftUI import Combine +import SwiftUI class MovieListViewModel: ObservableObject { @@ -7,12 +7,12 @@ class MovieListViewModel: ObservableObject { @Published var movieSorting: MovieSorting = .rating @Published var movies: [Movie] = [] @Published var hasFailed: Error? - + private var cancellables = Set() - + init(client: Client) { self.client = client - + $movieSorting .setFailureType(to: Error.self) .flatMap { self.client.movies(sorting: $0) } @@ -24,8 +24,9 @@ class MovieListViewModel: ObservableObject { case .finished: break } - }, - receiveValue: { self.movies = $0 } ) + }, + receiveValue: { self.movies = $0 } + ) .store(in: &cancellables) } } @@ -34,18 +35,20 @@ struct MovieListView: View { @ObservedObject var viewModel: MovieListViewModel var body: some View { List { - Section(header: - Picker("", selection: self.$viewModel.movieSorting) { - ForEach.init(MovieSorting.allCases) { sorting in - Text(sorting.rawValue.capitalized).tag(sorting) + Section( + header: + Picker("", selection: self.$viewModel.movieSorting) { + ForEach.init(MovieSorting.allCases) { sorting in + Text(sorting.rawValue.capitalized).tag(sorting) + } } - } - .pickerStyle(SegmentedPickerStyle()) - .padding()) - { + .pickerStyle(SegmentedPickerStyle()) + .padding() + ) { ForEach(viewModel.movies) { movie in NavigationLink( - destination: self.detailView(movie: movie)) { + destination: self.detailView(movie: movie) + ) { self.movieListRow(movie: movie) } } @@ -53,11 +56,11 @@ struct MovieListView: View { } .listStyle(GroupedListStyle()) } - + func detailView(movie: Movie) -> some View { MovieDetailView(viewModel: .init(client: self.viewModel.client, movie: movie)) } - + func movieListRow(movie: Movie) -> some View { MovieListRow(viewModel: .init(client: self.viewModel.client, movie: movie)) } diff --git a/Movies/Movies/UI/TopMoviesView.swift b/Movies/Movies/UI/TopMoviesView.swift index 9f55f399..5423d365 100644 --- a/Movies/Movies/UI/TopMoviesView.swift +++ b/Movies/Movies/UI/TopMoviesView.swift @@ -11,3 +11,9 @@ struct TopMoviesView: View { .accentColor(Color.yellow) } } + +struct TopMoviesView_Previews: PreviewProvider { + static var previews: some View { + TopMoviesView(client: LiveClient()) + } +} diff --git a/Movies/MoviesTests/ClientTests.swift b/Movies/MoviesTests/ClientTests.swift index 155017ba..7d7703a7 100644 --- a/Movies/MoviesTests/ClientTests.swift +++ b/Movies/MoviesTests/ClientTests.swift @@ -1,14 +1,15 @@ -import XCTest import Combine +import XCTest + class ClientTests: XCTestCase { - + var cancellables = Set() override func tearDownWithError() throws { cancellables.removeAll() } - + func testLiveClientMovieDecodable() throws { let client = LiveClient() let expectation = self.expectation(description: "receive value") @@ -22,17 +23,18 @@ class ClientTests: XCTestCase { case .finished: break } - }, + }, receiveValue: { value in expectation.fulfill() - }) + } + ) .store(in: &cancellables) wait(for: [expectation], timeout: 1) } - + func testLiveClientImage() throws { - // The image url, posterPath is the last component. + // The image url, path is the last component. // https://image.tmdb.org/t/p/w342/bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg //let path = "ndlQ2Cuc3cjTL7lTynw6I4boP4S.jpg" let path = "bvYjhsbxOBwpm8xLE5BhdA3a8CZ.jpg" @@ -48,12 +50,13 @@ class ClientTests: XCTestCase { case .finished: break } - }, + }, receiveValue: { value in expectation.fulfill() - }) + } + ) .store(in: &cancellables) - + wait(for: [expectation], timeout: 1) } diff --git a/README.md b/README.md index 8bb0c38d..a0c01b6c 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,30 @@ -# Work sample - Application developer +# Hello Daresay! -## Assignment +I enjoyed working with this assignment. The eight hour time-limit got me though, so no creative awesomeness or bonus features. Just the basic requirements with an unpolished (but nice and standard) UI. -- Build an awesome movie app that shows popular and high rated movies. -- Code it for the platform (Android, iOS, web) you applied for or the one you prefer. +My API key secret is not committed to the repository. Remember to add yours to the `LiveClient` when testing. Also, as a hint, Xcode will show a warning about it. -## Requirements +Things implemented / prioritized: +- Made using SwiftUI and Combine. +- Support for light and dark mode. +- Dynamic type (accessibility). +- Error handling is in place, but no UI for it yet. -- Use an open https://developers.themoviedb.org/open source API. Please read the API [Authentication section](https://developers.themoviedb.org/3/getting-started/authentication) to get started. -- Discover most popular and highly rated movies. -- Display the movies with creative look and feel of an app to meet design guidelines for your platform (Material Design etc.). -- Launch a detail screen whenever a particular movie is selected. +The next step if, I were to continue this app, would be to introduce a higher-order `Client` that wraps / maps other `Clients` and lets you transform and add side effects to them. Then you can simulate a flaky internet by adding delays and random errors to the `LiveClient`, which you otherwise can't since you don't have control over that endpoint. Then, adding spinners, pagination, error handling UI and testing edge cases would be nice and fun. Some more things to consider: -## Examples of bonus features +- A `MockClient` for offline development and testing. +- An option to choose between rows and grid in `MovieListView`. +- Pagination for the movie list (not just one page as it is now). +- Spinner when loading. +- UI for error handling when it can't be avoided by design. +- A more interesting and detailed `DetailView`. +- ... -- Allow user to save a favorite movie for offline access. -- Allow user to read movie reviews. +Well, I'm looking forward to discuss this assignment with you! -## We expect you to +Cheers, -- Write clean code. -- Create a responsive design. -- Handle error cases. -- Use the latest libraries and technologies. -- Tested code is a big plus. - -### User experience - -The features of the app might be few, but we expect you to deliver a solution with a high user experience. Imagine this application to be used by real users, with real needs. Make it interesting, fun and intuitive to use. And of course you are allowed to extend your applications functionality. - -### Code - -We expect that the code is of high quality and under source control. Expect the solution to be continuously worked on by other developers and should therefore be easy to understand, adjust and extend. True beauty starts on the inside! - -## Delivery - -Fork the repository, code in your fork and make a pull request when done. A nice commit history describing your work is preferred over squashing it into one commit. -Also send us an e-mail to let us know! - -### Good luck! +Gustaf Jorlin ---