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/.gitattributes b/Movies/.gitattributes new file mode 100644 index 00000000..8e45d030 --- /dev/null +++ b/Movies/.gitattributes @@ -0,0 +1 @@ +*.pbxproj merge=union diff --git a/Movies/Movies.xcodeproj/project.pbxproj b/Movies/Movies.xcodeproj/project.pbxproj new file mode 100644 index 00000000..c563b98a --- /dev/null +++ b/Movies/Movies.xcodeproj/project.pbxproj @@ -0,0 +1,577 @@ +// !$*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 */; }; + 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 */; }; + 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 */; }; + AB4AA196250C97CF007654B8 /* MovieListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4AA195250C97CF007654B8 /* MovieListView.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 */ + 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 = ""; }; + 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; }; + 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 = ""; }; + AB4AA195250C97CF007654B8 /* MovieListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieListView.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 */ + 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 = ( + AB91ACC0250CE70A00262B07 /* Extensions */, + AB4AA18A250BF309007654B8 /* Models */, + AB4AA183250BE52E007654B8 /* Client */, + AB4AA194250C97A5007654B8 /* UI */, + AB4AA15D250BC674007654B8 /* AppDelegate.swift */, + AB4AA15F250BC674007654B8 /* SceneDelegate.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 = ( + 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 */, + 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 = 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 */ + +/* 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; + }; + }; + }; + 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 = ( + 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 /* 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; + }; + AB4AA16C250BC676007654B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AB91ACB9250CB7A500262B07 /* Image.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; + }; +/* 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; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + 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; + }; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + 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; + }; + name = Release; + }; + AB4AA17D250BC676007654B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + 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"; + }; + name = Debug; + }; + AB4AA17E250BC676007654B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + 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"; + }; + 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..8941c103 --- /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/Client/Client.swift b/Movies/Movies/Client/Client.swift new file mode 100644 index 00000000..a8e96728 --- /dev/null +++ b/Movies/Movies/Client/Client.swift @@ -0,0 +1,22 @@ +import Combine +import UIKit + + +/// The interface for the movie API. +protocol Client { + + /// 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 new file mode 100644 index 00000000..54f26e34 --- /dev/null +++ b/Movies/Movies/Client/LiveClient.swift @@ -0,0 +1,189 @@ +import Combine +import UIKit + + +/// 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 +/// +/// And also, don't us use key secrets in apps ;) +/// https://nshipster.com/secrets/ +class LiveClient: Client { + #warning("Remove/Insert API Secret") + let key = "" + let session = URLSession.shared + + /// Fetches movies sorted by `MovieSorting`. + /// + /// - 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> { + // 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 + 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.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 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. + /// + /// This uses the hardcoded url discussed here: + /// https://www.themoviedb.org/talk/5aeaaf56c3a3682ddf0010de + /// + /// 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 + .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: DispatchQueue.main) + .eraseToAnyPublisher() + } +} + +extension LiveClient { + /// An intermediate struct for converting a API JSON response into `Movie` array. + struct ListResponse: Codable { + struct MovieResponse: Codable { + var id: Int + var title: String + var voteAverage: Double + var popularity: Double + var releaseDate: String + var posterPath: String? + var backdropPath: String? + var overview: String + } + var page: Int + var totalResults: Int + var totalPages: Int + var results: [MovieResponse] + } +} + +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 + ) + } +} + +extension MovieSorting { + /// Translates a `MovieSorting` to the corresponding query string used by the endpoint. + fileprivate var queryString: String { + switch self { + case .popularity: + return "popular" + case .rating: + return "top_rated" + } + } +} + +extension LiveClient { + /// Translates a `ImageSize` to the corresponding size string used by the endpoint. + fileprivate func sizeString(type: ImageType, size: ImageSize) -> String { + switch (type, size) { + case (.poster, .thumbnail): + return "w342" + case (.poster, .full): + return "original" + case (.backdrop, .thumbnail): + return "w300" + case (.backdrop, .full): + return "original" + } + } +} + +extension JSONDecoder { + /// A `JSONDecoder` configured to automatically converts from snake_case to camelCase. + 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 new file mode 100644 index 00000000..b5cb380c --- /dev/null +++ b/Movies/Movies/Extensions/Date.swift @@ -0,0 +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/Info.plist b/Movies/Movies/Info.plist new file mode 100644 index 00000000..cb967d41 --- /dev/null +++ b/Movies/Movies/Info.plist @@ -0,0 +1,58 @@ + + + + + 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 + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Movies/Movies/Models/Error.swift b/Movies/Movies/Models/Error.swift new file mode 100644 index 00000000..e1c13aef --- /dev/null +++ b/Movies/Movies/Models/Error.swift @@ -0,0 +1,8 @@ +import Foundation + +enum Error: Swift.Error { + case other(Swift.Error) + case decoding(DecodingError) + case url(URLError) + case decodingImage +} diff --git a/Movies/Movies/Models/Image.swift b/Movies/Movies/Models/Image.swift new file mode 100644 index 00000000..cb8b5213 --- /dev/null +++ b/Movies/Movies/Models/Image.swift @@ -0,0 +1,11 @@ +import Foundation + +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 new file mode 100644 index 00000000..2207029c --- /dev/null +++ b/Movies/Movies/Models/Movie.swift @@ -0,0 +1,12 @@ +import Foundation + +struct Movie: Identifiable { + var id: Int + var title: String + var rating: Double + var popularity: Double + var releaseDate: Date + var posterPath: String? + var backdropPath: String? + var overview: String +} diff --git a/Movies/Movies/Models/MovieSorting.swift b/Movies/Movies/Models/MovieSorting.swift new file mode 100644 index 00000000..28b2478a --- /dev/null +++ b/Movies/Movies/Models/MovieSorting.swift @@ -0,0 +1,7 @@ +import Foundation + +enum MovieSorting: String, CaseIterable, Identifiable { + case rating + case popularity + var id: String { self.rawValue } +} 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..9f47eda6 --- /dev/null +++ b/Movies/Movies/SceneDelegate.swift @@ -0,0 +1,23 @@ +import SwiftUI +import UIKit + + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene( + _ scene: UIScene, willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + + let view = TopMoviesView(client: LiveClient()) + + if let windowScene = scene as? UIWindowScene { + let window = UIWindow(windowScene: windowScene) + window.rootViewController = UIHostingController(rootView: view) + self.window = window + window.makeKeyAndVisible() + } + } +} diff --git a/Movies/Movies/UI/MovieDetail/MovieDetailView.swift b/Movies/Movies/UI/MovieDetail/MovieDetailView.swift new file mode 100644 index 00000000..4105fb08 --- /dev/null +++ b/Movies/Movies/UI/MovieDetail/MovieDetailView.swift @@ -0,0 +1,81 @@ +import Combine +import SwiftUI + +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/MovieList/MovieListRow.swift b/Movies/Movies/UI/MovieList/MovieListRow.swift new file mode 100644 index 00000000..7d07e749 --- /dev/null +++ b/Movies/Movies/UI/MovieList/MovieListRow.swift @@ -0,0 +1,73 @@ +import Combine +import SwiftUI + + +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) { + self.client = client + self.movie = movie + + if let posterPath = movie.posterPath { + client + .image(path: posterPath, type: .poster, 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) + .frame(height: 120) + VStack(alignment: .leading) { + Text(viewModel.movie.title) + .font(.headline) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + 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() + } + } +} + +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", + overview: "overview")) + ) + } +} diff --git a/Movies/Movies/UI/MovieList/MovieListView.swift b/Movies/Movies/UI/MovieList/MovieListView.swift new file mode 100644 index 00000000..64a669ba --- /dev/null +++ b/Movies/Movies/UI/MovieList/MovieListView.swift @@ -0,0 +1,76 @@ +import Combine +import SwiftUI + + +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 + 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 { + static var previews: some View { + NavigationView { + MovieListView(viewModel: .init(client: LiveClient())) + .navigationBarTitle("Top Movies", displayMode: .inline) + } + } +} diff --git a/Movies/Movies/UI/TopMoviesView.swift b/Movies/Movies/UI/TopMoviesView.swift new file mode 100644 index 00000000..5423d365 --- /dev/null +++ b/Movies/Movies/UI/TopMoviesView.swift @@ -0,0 +1,19 @@ +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) + } +} + +struct TopMoviesView_Previews: PreviewProvider { + static var previews: some View { + TopMoviesView(client: LiveClient()) + } +} diff --git a/Movies/MoviesTests/ClientTests.swift b/Movies/MoviesTests/ClientTests.swift new file mode 100644 index 00000000..7d7703a7 --- /dev/null +++ b/Movies/MoviesTests/ClientTests.swift @@ -0,0 +1,63 @@ +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") + client + .movies(sorting: .rating) + .sink( + receiveCompletion: { + switch $0 { + case .failure(let error): + XCTFail("\(error)") + case .finished: + break + } + }, + receiveValue: { value in + expectation.fulfill() + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + + func testLiveClientImage() throws { + // 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" + let client = LiveClient() + let expectation = self.expectation(description: "receive value") + client + .image(path: path, type: .backdrop, size: .thumbnail) + .sink( + receiveCompletion: { + switch $0 { + case .failure(let error): + XCTFail("\(error)") + case .finished: + break + } + }, + receiveValue: { value in + expectation.fulfill() + } + ) + .store(in: &cancellables) + + wait(for: [expectation], timeout: 1) + } + +} 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..baa8b31a --- /dev/null +++ b/Movies/MoviesTests/MoviesTests.swift @@ -0,0 +1,25 @@ +import XCTest + +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. + } + } + +} 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 ---