├── .gitignore ├── README.md ├── SwiftTalk.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ └── SwiftTalk.xcscheme └── SwiftTalk ├── App.swift ├── AppDelegate.swift ├── Assets.xcassets ├── App Icon & Top Shelf Image.brandassets │ ├── App Icon - Large.imagestack │ │ ├── Back.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Front.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Middle.imagestacklayer │ │ │ ├── Content.imageset │ │ │ └── Contents.json │ │ │ └── Contents.json │ ├── App Icon - Small.imagestack │ │ ├── Back.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── Front.imagestacklayer │ │ │ ├── Content.imageset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ └── Middle.imagestacklayer │ │ │ ├── Content.imageset │ │ │ └── Contents.json │ │ │ └── Contents.json │ ├── Contents.json │ ├── Top Shelf Image Wide.imageset │ │ └── Contents.json │ └── Top Shelf Image.imageset │ │ └── Contents.json ├── Contents.json ├── LaunchImage.launchimage │ └── Contents.json └── placeholder.imageset │ ├── Contents.json │ └── placeholder.png ├── Bridging-Header.h ├── Caching.swift ├── DownloadManager.swift ├── Environment.swift ├── EpisodeCell.swift ├── EpisodeDetailViewController.swift ├── EpisodeModel.swift ├── EpisodeViewModel.swift ├── EpisodesListViewController.swift ├── Extensions.swift ├── Info.plist ├── Keychain.swift ├── Login.swift ├── LoginModel.swift ├── LoginViewController.swift ├── Notifications.swift ├── Resource.swift ├── Screens.swift ├── StackView.swift ├── StandardViewControllers.swift ├── VideoPlayerViewController.swift └── Webservice.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata 19 | 20 | ## Other 21 | *.xccheckout 22 | *.moved-aside 23 | *.xcuserstate 24 | *.xcscmblueprint 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | *.ipa 29 | 30 | # CocoaPods 31 | # 32 | # We recommend against adding the Pods directory to your .gitignore. However 33 | # you should judge for yourself, the pros and cons are mentioned at: 34 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control 35 | # 36 | # Pods/ 37 | 38 | # Carthage 39 | # 40 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 41 | # Carthage/Checkouts 42 | 43 | Carthage/Build 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Swift Talk 2 | ## View Controller Refactoring 3 | 4 | This is the code that accompanies Swift Talk Episode 43: [View Controller Refactoring](https://talk.objc.io/episodes/S01E43-view-controller-refactoring) 5 | -------------------------------------------------------------------------------- /SwiftTalk.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 5D1B34AD1E54A36E00B5249D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1B34AC1E54A36E00B5249D /* AppDelegate.swift */; }; 11 | 5D1B34B41E54A36E00B5249D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D1B34B31E54A36E00B5249D /* Assets.xcassets */; }; 12 | 5DB061001E71AFDC00C20F6D /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB060FF1E71AFDC00C20F6D /* EpisodeCell.swift */; }; 13 | 5DF85FA01E65A08900EECF29 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85F9F1E65A08900EECF29 /* App.swift */; }; 14 | 5DF85FA21E65A0E100EECF29 /* Screens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FA11E65A0E100EECF29 /* Screens.swift */; }; 15 | 5DF85FA41E65A3FA00EECF29 /* StandardViewControllers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FA31E65A3FA00EECF29 /* StandardViewControllers.swift */; }; 16 | 5DF85FA61E65A40600EECF29 /* EpisodeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FA51E65A40600EECF29 /* EpisodeModel.swift */; }; 17 | 5DF85FA81E65A45300EECF29 /* Webservice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FA71E65A45300EECF29 /* Webservice.swift */; }; 18 | 5DF85FAB1E65A46700EECF29 /* Caching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FA91E65A46700EECF29 /* Caching.swift */; }; 19 | 5DF85FAC1E65A46700EECF29 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FAA1E65A46700EECF29 /* DownloadManager.swift */; }; 20 | 5DF85FAE1E65A51400EECF29 /* Resource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FAD1E65A51400EECF29 /* Resource.swift */; }; 21 | 5DF85FB01E65A75900EECF29 /* EpisodeDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FAF1E65A75900EECF29 /* EpisodeDetailViewController.swift */; }; 22 | 5DF85FB21E65A7A300EECF29 /* StackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FB11E65A7A300EECF29 /* StackView.swift */; }; 23 | 5DF85FB51E65AB0600EECF29 /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FB41E65AB0600EECF29 /* Extensions.swift */; }; 24 | 5DF85FB71E65AB1C00EECF29 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FB61E65AB1C00EECF29 /* Environment.swift */; }; 25 | 5DF85FB91E65B32900EECF29 /* EpisodeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FB81E65B32900EECF29 /* EpisodeViewModel.swift */; }; 26 | 5DF85FBD1E65E7E700EECF29 /* VideoPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FBC1E65E7E700EECF29 /* VideoPlayerViewController.swift */; }; 27 | 5DF85FBF1E65F42B00EECF29 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FBE1E65F42B00EECF29 /* Notifications.swift */; }; 28 | 5DF85FC11E65FAC500EECF29 /* LoginModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FC01E65FAC500EECF29 /* LoginModel.swift */; }; 29 | 5DF85FC31E65FC6F00EECF29 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FC21E65FC6F00EECF29 /* LoginViewController.swift */; }; 30 | 5DF85FC51E67247700EECF29 /* Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FC41E67247700EECF29 /* Login.swift */; }; 31 | 5DF85FC71E673A8C00EECF29 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DF85FC61E673A8C00EECF29 /* Keychain.swift */; }; 32 | 83A8F5961E782E31005959E7 /* EpisodesListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A8F5951E782E31005959E7 /* EpisodesListViewController.swift */; }; 33 | /* End PBXBuildFile section */ 34 | 35 | /* Begin PBXFileReference section */ 36 | 5D1B34A91E54A36E00B5249D /* SwiftTalk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftTalk.app; sourceTree = BUILT_PRODUCTS_DIR; }; 37 | 5D1B34AC1E54A36E00B5249D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 38 | 5D1B34B31E54A36E00B5249D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 39 | 5D1B34B51E54A36E00B5249D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 40 | 5DB060FF1E71AFDC00C20F6D /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 41 | 5DF85F9F1E65A08900EECF29 /* App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; 42 | 5DF85FA11E65A0E100EECF29 /* Screens.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Screens.swift; sourceTree = ""; }; 43 | 5DF85FA31E65A3FA00EECF29 /* StandardViewControllers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StandardViewControllers.swift; sourceTree = ""; }; 44 | 5DF85FA51E65A40600EECF29 /* EpisodeModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeModel.swift; sourceTree = ""; }; 45 | 5DF85FA71E65A45300EECF29 /* Webservice.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Webservice.swift; sourceTree = ""; }; 46 | 5DF85FA91E65A46700EECF29 /* Caching.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Caching.swift; sourceTree = ""; }; 47 | 5DF85FAA1E65A46700EECF29 /* DownloadManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 48 | 5DF85FAD1E65A51400EECF29 /* Resource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Resource.swift; sourceTree = ""; }; 49 | 5DF85FAF1E65A75900EECF29 /* EpisodeDetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeDetailViewController.swift; sourceTree = ""; }; 50 | 5DF85FB11E65A7A300EECF29 /* StackView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackView.swift; sourceTree = ""; }; 51 | 5DF85FB31E65AAB800EECF29 /* Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Bridging-Header.h"; sourceTree = ""; }; 52 | 5DF85FB41E65AB0600EECF29 /* Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 53 | 5DF85FB61E65AB1C00EECF29 /* Environment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 54 | 5DF85FB81E65B32900EECF29 /* EpisodeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeViewModel.swift; sourceTree = ""; }; 55 | 5DF85FBC1E65E7E700EECF29 /* VideoPlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewController.swift; sourceTree = ""; }; 56 | 5DF85FBE1E65F42B00EECF29 /* Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 57 | 5DF85FC01E65FAC500EECF29 /* LoginModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginModel.swift; sourceTree = ""; }; 58 | 5DF85FC21E65FC6F00EECF29 /* LoginViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; 59 | 5DF85FC41E67247700EECF29 /* Login.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Login.swift; sourceTree = ""; }; 60 | 5DF85FC61E673A8C00EECF29 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; 61 | 83A8F5951E782E31005959E7 /* EpisodesListViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodesListViewController.swift; sourceTree = ""; }; 62 | /* End PBXFileReference section */ 63 | 64 | /* Begin PBXFrameworksBuildPhase section */ 65 | 5D1B34A61E54A36E00B5249D /* Frameworks */ = { 66 | isa = PBXFrameworksBuildPhase; 67 | buildActionMask = 2147483647; 68 | files = ( 69 | ); 70 | runOnlyForDeploymentPostprocessing = 0; 71 | }; 72 | /* End PBXFrameworksBuildPhase section */ 73 | 74 | /* Begin PBXGroup section */ 75 | 5D1B34A01E54A36E00B5249D = { 76 | isa = PBXGroup; 77 | children = ( 78 | 5D1B34AB1E54A36E00B5249D /* SwiftTalk */, 79 | 5D1B34AA1E54A36E00B5249D /* Products */, 80 | ); 81 | sourceTree = ""; 82 | }; 83 | 5D1B34AA1E54A36E00B5249D /* Products */ = { 84 | isa = PBXGroup; 85 | children = ( 86 | 5D1B34A91E54A36E00B5249D /* SwiftTalk.app */, 87 | ); 88 | name = Products; 89 | sourceTree = ""; 90 | }; 91 | 5D1B34AB1E54A36E00B5249D /* SwiftTalk */ = { 92 | isa = PBXGroup; 93 | children = ( 94 | 5DF85F9F1E65A08900EECF29 /* App.swift */, 95 | 5D1B34AC1E54A36E00B5249D /* AppDelegate.swift */, 96 | 5DF85FA91E65A46700EECF29 /* Caching.swift */, 97 | 5DF85FAA1E65A46700EECF29 /* DownloadManager.swift */, 98 | 5DF85FB61E65AB1C00EECF29 /* Environment.swift */, 99 | 5DB060FF1E71AFDC00C20F6D /* EpisodeCell.swift */, 100 | 5DF85FA51E65A40600EECF29 /* EpisodeModel.swift */, 101 | 83A8F5951E782E31005959E7 /* EpisodesListViewController.swift */, 102 | 5DF85FAF1E65A75900EECF29 /* EpisodeDetailViewController.swift */, 103 | 5DF85FB81E65B32900EECF29 /* EpisodeViewModel.swift */, 104 | 5DF85FB41E65AB0600EECF29 /* Extensions.swift */, 105 | 5DF85FC61E673A8C00EECF29 /* Keychain.swift */, 106 | 5DF85FC41E67247700EECF29 /* Login.swift */, 107 | 5DF85FC01E65FAC500EECF29 /* LoginModel.swift */, 108 | 5DF85FC21E65FC6F00EECF29 /* LoginViewController.swift */, 109 | 5DF85FBE1E65F42B00EECF29 /* Notifications.swift */, 110 | 5DF85FAD1E65A51400EECF29 /* Resource.swift */, 111 | 5DF85FA11E65A0E100EECF29 /* Screens.swift */, 112 | 5DF85FB11E65A7A300EECF29 /* StackView.swift */, 113 | 5DF85FA31E65A3FA00EECF29 /* StandardViewControllers.swift */, 114 | 5DF85FBC1E65E7E700EECF29 /* VideoPlayerViewController.swift */, 115 | 5DF85FA71E65A45300EECF29 /* Webservice.swift */, 116 | 5DF85FB31E65AAB800EECF29 /* Bridging-Header.h */, 117 | 5D1B34B31E54A36E00B5249D /* Assets.xcassets */, 118 | 5D1B34B51E54A36E00B5249D /* Info.plist */, 119 | ); 120 | path = SwiftTalk; 121 | sourceTree = ""; 122 | }; 123 | /* End PBXGroup section */ 124 | 125 | /* Begin PBXNativeTarget section */ 126 | 5D1B34A81E54A36E00B5249D /* SwiftTalk */ = { 127 | isa = PBXNativeTarget; 128 | buildConfigurationList = 5D1B34B81E54A36E00B5249D /* Build configuration list for PBXNativeTarget "SwiftTalk" */; 129 | buildPhases = ( 130 | 5D1B34A51E54A36E00B5249D /* Sources */, 131 | 5D1B34A61E54A36E00B5249D /* Frameworks */, 132 | 5D1B34A71E54A36E00B5249D /* Resources */, 133 | ); 134 | buildRules = ( 135 | ); 136 | dependencies = ( 137 | ); 138 | name = SwiftTalk; 139 | productName = SwiftTalk; 140 | productReference = 5D1B34A91E54A36E00B5249D /* SwiftTalk.app */; 141 | productType = "com.apple.product-type.application"; 142 | }; 143 | /* End PBXNativeTarget section */ 144 | 145 | /* Begin PBXProject section */ 146 | 5D1B34A11E54A36E00B5249D /* Project object */ = { 147 | isa = PBXProject; 148 | attributes = { 149 | LastSwiftUpdateCheck = 0820; 150 | LastUpgradeCheck = 0820; 151 | ORGANIZATIONNAME = objc.io; 152 | TargetAttributes = { 153 | 5D1B34A81E54A36E00B5249D = { 154 | CreatedOnToolsVersion = 8.2; 155 | DevelopmentTeam = UQBP8YQ495; 156 | ProvisioningStyle = Automatic; 157 | }; 158 | }; 159 | }; 160 | buildConfigurationList = 5D1B34A41E54A36E00B5249D /* Build configuration list for PBXProject "SwiftTalk" */; 161 | compatibilityVersion = "Xcode 3.2"; 162 | developmentRegion = English; 163 | hasScannedForEncodings = 0; 164 | knownRegions = ( 165 | en, 166 | Base, 167 | ); 168 | mainGroup = 5D1B34A01E54A36E00B5249D; 169 | productRefGroup = 5D1B34AA1E54A36E00B5249D /* Products */; 170 | projectDirPath = ""; 171 | projectRoot = ""; 172 | targets = ( 173 | 5D1B34A81E54A36E00B5249D /* SwiftTalk */, 174 | ); 175 | }; 176 | /* End PBXProject section */ 177 | 178 | /* Begin PBXResourcesBuildPhase section */ 179 | 5D1B34A71E54A36E00B5249D /* Resources */ = { 180 | isa = PBXResourcesBuildPhase; 181 | buildActionMask = 2147483647; 182 | files = ( 183 | 5D1B34B41E54A36E00B5249D /* Assets.xcassets in Resources */, 184 | ); 185 | runOnlyForDeploymentPostprocessing = 0; 186 | }; 187 | /* End PBXResourcesBuildPhase section */ 188 | 189 | /* Begin PBXSourcesBuildPhase section */ 190 | 5D1B34A51E54A36E00B5249D /* Sources */ = { 191 | isa = PBXSourcesBuildPhase; 192 | buildActionMask = 2147483647; 193 | files = ( 194 | 5DF85FA01E65A08900EECF29 /* App.swift in Sources */, 195 | 5DF85FA81E65A45300EECF29 /* Webservice.swift in Sources */, 196 | 5DF85FA41E65A3FA00EECF29 /* StandardViewControllers.swift in Sources */, 197 | 83A8F5961E782E31005959E7 /* EpisodesListViewController.swift in Sources */, 198 | 5DB061001E71AFDC00C20F6D /* EpisodeCell.swift in Sources */, 199 | 5DF85FB71E65AB1C00EECF29 /* Environment.swift in Sources */, 200 | 5DF85FC31E65FC6F00EECF29 /* LoginViewController.swift in Sources */, 201 | 5DF85FAE1E65A51400EECF29 /* Resource.swift in Sources */, 202 | 5DF85FA61E65A40600EECF29 /* EpisodeModel.swift in Sources */, 203 | 5DF85FAB1E65A46700EECF29 /* Caching.swift in Sources */, 204 | 5DF85FC11E65FAC500EECF29 /* LoginModel.swift in Sources */, 205 | 5D1B34AD1E54A36E00B5249D /* AppDelegate.swift in Sources */, 206 | 5DF85FC51E67247700EECF29 /* Login.swift in Sources */, 207 | 5DF85FB51E65AB0600EECF29 /* Extensions.swift in Sources */, 208 | 5DF85FB91E65B32900EECF29 /* EpisodeViewModel.swift in Sources */, 209 | 5DF85FB01E65A75900EECF29 /* EpisodeDetailViewController.swift in Sources */, 210 | 5DF85FAC1E65A46700EECF29 /* DownloadManager.swift in Sources */, 211 | 5DF85FBF1E65F42B00EECF29 /* Notifications.swift in Sources */, 212 | 5DF85FBD1E65E7E700EECF29 /* VideoPlayerViewController.swift in Sources */, 213 | 5DF85FA21E65A0E100EECF29 /* Screens.swift in Sources */, 214 | 5DF85FB21E65A7A300EECF29 /* StackView.swift in Sources */, 215 | 5DF85FC71E673A8C00EECF29 /* Keychain.swift in Sources */, 216 | ); 217 | runOnlyForDeploymentPostprocessing = 0; 218 | }; 219 | /* End PBXSourcesBuildPhase section */ 220 | 221 | /* Begin XCBuildConfiguration section */ 222 | 5D1B34B61E54A36E00B5249D /* Debug */ = { 223 | isa = XCBuildConfiguration; 224 | buildSettings = { 225 | ALWAYS_SEARCH_USER_PATHS = NO; 226 | CLANG_ANALYZER_NONNULL = YES; 227 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 228 | CLANG_CXX_LIBRARY = "libc++"; 229 | CLANG_ENABLE_MODULES = YES; 230 | CLANG_ENABLE_OBJC_ARC = YES; 231 | CLANG_WARN_BOOL_CONVERSION = YES; 232 | CLANG_WARN_CONSTANT_CONVERSION = YES; 233 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 234 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 235 | CLANG_WARN_EMPTY_BODY = YES; 236 | CLANG_WARN_ENUM_CONVERSION = YES; 237 | CLANG_WARN_INFINITE_RECURSION = YES; 238 | CLANG_WARN_INT_CONVERSION = YES; 239 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 240 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 241 | CLANG_WARN_UNREACHABLE_CODE = YES; 242 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 243 | COPY_PHASE_STRIP = NO; 244 | DEBUG_INFORMATION_FORMAT = dwarf; 245 | ENABLE_STRICT_OBJC_MSGSEND = YES; 246 | ENABLE_TESTABILITY = YES; 247 | GCC_C_LANGUAGE_STANDARD = gnu99; 248 | GCC_DYNAMIC_NO_PIC = NO; 249 | GCC_NO_COMMON_BLOCKS = YES; 250 | GCC_OPTIMIZATION_LEVEL = 0; 251 | GCC_PREPROCESSOR_DEFINITIONS = ( 252 | "DEBUG=1", 253 | "$(inherited)", 254 | ); 255 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 256 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 257 | GCC_WARN_UNDECLARED_SELECTOR = YES; 258 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 259 | GCC_WARN_UNUSED_FUNCTION = YES; 260 | GCC_WARN_UNUSED_VARIABLE = YES; 261 | MTL_ENABLE_DEBUG_INFO = YES; 262 | ONLY_ACTIVE_ARCH = YES; 263 | SDKROOT = appletvos; 264 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 265 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 266 | TARGETED_DEVICE_FAMILY = 3; 267 | TVOS_DEPLOYMENT_TARGET = 10.1; 268 | }; 269 | name = Debug; 270 | }; 271 | 5D1B34B71E54A36E00B5249D /* Release */ = { 272 | isa = XCBuildConfiguration; 273 | buildSettings = { 274 | ALWAYS_SEARCH_USER_PATHS = NO; 275 | CLANG_ANALYZER_NONNULL = YES; 276 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 277 | CLANG_CXX_LIBRARY = "libc++"; 278 | CLANG_ENABLE_MODULES = YES; 279 | CLANG_ENABLE_OBJC_ARC = YES; 280 | CLANG_WARN_BOOL_CONVERSION = YES; 281 | CLANG_WARN_CONSTANT_CONVERSION = YES; 282 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 283 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 284 | CLANG_WARN_EMPTY_BODY = YES; 285 | CLANG_WARN_ENUM_CONVERSION = YES; 286 | CLANG_WARN_INFINITE_RECURSION = YES; 287 | CLANG_WARN_INT_CONVERSION = YES; 288 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 289 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 290 | CLANG_WARN_UNREACHABLE_CODE = YES; 291 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 292 | COPY_PHASE_STRIP = NO; 293 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 294 | ENABLE_NS_ASSERTIONS = NO; 295 | ENABLE_STRICT_OBJC_MSGSEND = YES; 296 | GCC_C_LANGUAGE_STANDARD = gnu99; 297 | GCC_NO_COMMON_BLOCKS = YES; 298 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 299 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 300 | GCC_WARN_UNDECLARED_SELECTOR = YES; 301 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 302 | GCC_WARN_UNUSED_FUNCTION = YES; 303 | GCC_WARN_UNUSED_VARIABLE = YES; 304 | MTL_ENABLE_DEBUG_INFO = NO; 305 | SDKROOT = appletvos; 306 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 307 | TARGETED_DEVICE_FAMILY = 3; 308 | TVOS_DEPLOYMENT_TARGET = 10.1; 309 | VALIDATE_PRODUCT = YES; 310 | }; 311 | name = Release; 312 | }; 313 | 5D1B34B91E54A36E00B5249D /* Debug */ = { 314 | isa = XCBuildConfiguration; 315 | buildSettings = { 316 | ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; 317 | ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; 318 | DEVELOPMENT_TEAM = UQBP8YQ495; 319 | INFOPLIST_FILE = SwiftTalk/Info.plist; 320 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 321 | PRODUCT_BUNDLE_IDENTIFIER = io.objcio.videos.tv; 322 | PRODUCT_NAME = "$(TARGET_NAME)"; 323 | SWIFT_OBJC_BRIDGING_HEADER = "SwiftTalk/Bridging-Header.h"; 324 | SWIFT_VERSION = 3.0; 325 | }; 326 | name = Debug; 327 | }; 328 | 5D1B34BA1E54A36E00B5249D /* Release */ = { 329 | isa = XCBuildConfiguration; 330 | buildSettings = { 331 | ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image"; 332 | ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage; 333 | DEVELOPMENT_TEAM = UQBP8YQ495; 334 | INFOPLIST_FILE = SwiftTalk/Info.plist; 335 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 336 | PRODUCT_BUNDLE_IDENTIFIER = io.objcio.videos.tv; 337 | PRODUCT_NAME = "$(TARGET_NAME)"; 338 | SWIFT_OBJC_BRIDGING_HEADER = "SwiftTalk/Bridging-Header.h"; 339 | SWIFT_VERSION = 3.0; 340 | }; 341 | name = Release; 342 | }; 343 | /* End XCBuildConfiguration section */ 344 | 345 | /* Begin XCConfigurationList section */ 346 | 5D1B34A41E54A36E00B5249D /* Build configuration list for PBXProject "SwiftTalk" */ = { 347 | isa = XCConfigurationList; 348 | buildConfigurations = ( 349 | 5D1B34B61E54A36E00B5249D /* Debug */, 350 | 5D1B34B71E54A36E00B5249D /* Release */, 351 | ); 352 | defaultConfigurationIsVisible = 0; 353 | defaultConfigurationName = Release; 354 | }; 355 | 5D1B34B81E54A36E00B5249D /* Build configuration list for PBXNativeTarget "SwiftTalk" */ = { 356 | isa = XCConfigurationList; 357 | buildConfigurations = ( 358 | 5D1B34B91E54A36E00B5249D /* Debug */, 359 | 5D1B34BA1E54A36E00B5249D /* Release */, 360 | ); 361 | defaultConfigurationIsVisible = 0; 362 | defaultConfigurationName = Release; 363 | }; 364 | /* End XCConfigurationList section */ 365 | }; 366 | rootObject = 5D1B34A11E54A36E00B5249D /* Project object */; 367 | } 368 | -------------------------------------------------------------------------------- /SwiftTalk.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /SwiftTalk.xcodeproj/xcshareddata/xcschemes/SwiftTalk.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 54 | 56 | 62 | 63 | 64 | 65 | 69 | 70 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /SwiftTalk/App.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class App { 4 | private let application: UIApplication 5 | private let screens: Screens 6 | private let rootScreen: UITabBarController 7 | private let login: Login 8 | private let webservice = Webservice() 9 | private let navigationDelegate = EpisodesNavigationDelegate() 10 | 11 | public init(application: UIApplication, window: UIWindow) { 12 | self.application = application 13 | screens = Screens(webservice: webservice) 14 | login = Login(webservice: webservice) 15 | rootScreen = UITabBarController() 16 | rootScreen.viewControllers = [videosTab(), login.screen] 17 | window.rootViewController = rootScreen 18 | window.makeKeyAndVisible() 19 | login.stateDidChange = { [unowned self] in self.loginStateDidChange(state: $0) } 20 | webservice.authenticationToken = login.authenticationToken 21 | // TODO: Do we need this on tvOS? Should it be handled inside LoginViewController? 22 | // screens.authenticationFailure = { [unowned self] in self.showLogin() } 23 | } 24 | 25 | private func loginStateDidChange(state: Login.State) { 26 | switch state { 27 | case .signedOut, 28 | .requestingAuthCode, 29 | .requestingAuthCodeFailed(_), 30 | .receivedAuthCode(_): 31 | webservice.authenticationToken = nil 32 | case .signedIn(let token): 33 | webservice.authenticationToken = token 34 | } 35 | // TODO: update/reload UI after auth state changed 36 | } 37 | 38 | private func videosTab() -> UIViewController { 39 | let navVC = screens.videosTab() 40 | let episodesScreen = screens.allEpisodes { [unowned self, unowned navVC] episode in 41 | let episodeVC = self.screens.episode(episode, didTapPlay: { [unowned self, unowned navVC] episode in 42 | let videoPlayer = self.screens.video(episode) 43 | videoPlayer.didPlayToEnd = { [unowned navVC] in navVC.popViewController(animated: true) } 44 | navVC.show(videoPlayer, sender: self) 45 | videoPlayer.play() 46 | }) 47 | navVC.show(episodeVC, sender: self) 48 | } 49 | navVC.delegate = navigationDelegate 50 | navVC.viewControllers = [episodesScreen] 51 | return navVC 52 | } 53 | } 54 | 55 | final class EpisodesNavigationDelegate: NSObject, UINavigationControllerDelegate { 56 | func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { 57 | let isRootVC = navigationController.viewControllers.first == viewController 58 | navigationController.setNavigationBarHidden(!isRootVC, animated: animated) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /SwiftTalk/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | var window: UIWindow? 6 | var app: App? 7 | 8 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 9 | window = UIWindow(frame: UIScreen.main.bounds) 10 | app = App(application: application, window: window!) 11 | return true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "layers" : [ 3 | { 4 | "filename" : "Front.imagestacklayer" 5 | }, 6 | { 7 | "filename" : "Middle.imagestacklayer" 8 | }, 9 | { 10 | "filename" : "Back.imagestacklayer" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Large.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Back.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "layers" : [ 3 | { 4 | "filename" : "Front.imagestacklayer" 5 | }, 6 | { 7 | "filename" : "Middle.imagestacklayer" 8 | }, 9 | { 10 | "filename" : "Back.imagestacklayer" 11 | } 12 | ], 13 | "info" : { 14 | "version" : 1, 15 | "author" : "xcode" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Front.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon - Small.imagestack/Middle.imagestacklayer/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "assets" : [ 3 | { 4 | "size" : "1280x768", 5 | "idiom" : "tv", 6 | "filename" : "App Icon - Large.imagestack", 7 | "role" : "primary-app-icon" 8 | }, 9 | { 10 | "size" : "400x240", 11 | "idiom" : "tv", 12 | "filename" : "App Icon - Small.imagestack", 13 | "role" : "primary-app-icon" 14 | }, 15 | { 16 | "size" : "2320x720", 17 | "idiom" : "tv", 18 | "filename" : "Top Shelf Image Wide.imageset", 19 | "role" : "top-shelf-image-wide" 20 | }, 21 | { 22 | "size" : "1920x720", 23 | "idiom" : "tv", 24 | "filename" : "Top Shelf Image.imageset", 25 | "role" : "top-shelf-image" 26 | } 27 | ], 28 | "info" : { 29 | "version" : 1, 30 | "author" : "xcode" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image Wide.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Top Shelf Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "tv", 5 | "scale" : "1x" 6 | } 7 | ], 8 | "info" : { 9 | "version" : 1, 10 | "author" : "xcode" 11 | } 12 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/LaunchImage.launchimage/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "orientation" : "landscape", 5 | "idiom" : "tv", 6 | "extent" : "full-screen", 7 | "minimum-system-version" : "9.0", 8 | "scale" : "1x" 9 | } 10 | ], 11 | "info" : { 12 | "version" : 1, 13 | "author" : "xcode" 14 | } 15 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/placeholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "placeholder.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /SwiftTalk/Assets.xcassets/placeholder.imageset/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/objcio/S01E43-view-controller-refactoring/98e862928f3b9d2345dc8d7e42aee1f683208fb0/SwiftTalk/Assets.xcassets/placeholder.imageset/placeholder.png -------------------------------------------------------------------------------- /SwiftTalk/Bridging-Header.h: -------------------------------------------------------------------------------- 1 | // 2 | // Videos-Bridging-Header.h 3 | // Videos 4 | // 5 | // Created by Florian Kugler on 01-11-2016. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | #ifndef Videos_Bridging_Header_h 10 | #define Videos_Bridging_Header_h 11 | 12 | #import 13 | 14 | #endif /* Videos_Bridging_Header_h */ 15 | -------------------------------------------------------------------------------- /SwiftTalk/Caching.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Caching.swift 3 | // Videos 4 | // 5 | // Created by Florian Kugler on 01-11-2016. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Resource { 12 | var hash: String { 13 | return url.absoluteString.sha1() 14 | } 15 | } 16 | 17 | final class DiskCache { 18 | init() { } 19 | private let cacheDirectory: URL = 20 | try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) 21 | 22 | private func cacheLocation(of resource: Resource) -> URL { 23 | let key = resource.hash 24 | return cacheDirectory.appendingPathComponent(key) 25 | } 26 | 27 | func load(_ resource: Resource) -> A? { 28 | guard case .get = resource.method else { 29 | return nil 30 | } 31 | 32 | let data = try? Data(contentsOf: cacheLocation(of: resource)) 33 | return data.flatMap(resource.parse) 34 | } 35 | 36 | func save(_ data: Data, for resource: Resource) { 37 | guard case .get = resource.method else { return } 38 | let url = cacheLocation(of: resource) 39 | try? data.write(to: url) 40 | } 41 | } 42 | 43 | public final class CachedWebservice { 44 | private let webservice: Webservice 45 | private let cache: DiskCache = DiskCache() 46 | 47 | public init(webservice: Webservice) { 48 | self.webservice = webservice 49 | } 50 | 51 | public func load(_ resource: Resource, skipCache: Bool, update: @escaping (Result) -> () = logError) { 52 | let dataResource: Resource = Resource(url: resource.url, parse: { $0 }, method: resource.method) 53 | 54 | if skipCache == false, let result = cache.load(resource) { 55 | update(.success(result)) 56 | } 57 | 58 | webservice.load(dataResource) { result in 59 | switch result { 60 | case let .success(data): 61 | self.cache.save(data, for: dataResource) 62 | update(Result(resource.parse(data), or: WebServiceError.other)) 63 | case let .error(err): 64 | update(.error(err)) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SwiftTalk/DownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDownloader.swift 3 | // VideoDownloads 4 | // 5 | // Created by Chris Eidhof on 03/11/2016. 6 | // Copyright © 2016 objc.io. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct DownloadState { 12 | enum State { 13 | case pausedByUser 14 | case waitingForConnection 15 | case inProgress 16 | case cancelled 17 | case finished 18 | } 19 | 20 | let url: URL 21 | var state: State = .pausedByUser 22 | var progress: Double = 0 23 | } 24 | 25 | final class DownloadManager: NSObject, URLSessionDownloadDelegate { 26 | var stateChanged: ((DownloadState) -> ())? 27 | var saveDownload: ((URL, URL) -> ())? // original, tempFile 28 | 29 | private var _session: URLSession? 30 | private var session: URLSession { 31 | return _session! 32 | } 33 | private var states: [URLSessionTask: DownloadState] = [:] 34 | 35 | private func task(for url: URL) -> URLSessionTask? { 36 | return states.first { key, value in 37 | return value.url == url 38 | }?.0 39 | } 40 | 41 | private func modifyState(for task: URLSessionTask, transform: (inout DownloadState) -> ()) { 42 | transform(&states[task]!) 43 | stateChanged?(states[task]!) 44 | } 45 | 46 | override init() { 47 | super.init() 48 | // This creates a reference cycle because the delegate is strongly retained. not a problem in practice because VideoDownloader is a singleton. 49 | let configuration = URLSessionConfiguration.background(withIdentifier: "io.objc.background") 50 | configuration.allowsCellularAccess = false 51 | _session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) 52 | } 53 | 54 | func start(url: URL) { 55 | let task = self.task(for: url) ?? session.downloadTask(with: url) 56 | let progress = states[task]?.progress ?? 0 57 | states[task] = DownloadState(url: url, state: .inProgress, progress: progress) 58 | task.resume() 59 | } 60 | 61 | func cancel(url: URL) { 62 | guard let task = task(for: url) else { 63 | fatalError("There should be a task") 64 | } 65 | task.cancel() 66 | modifyState(for: task) { 67 | $0.state = .cancelled 68 | } 69 | states[task] = nil 70 | } 71 | 72 | func pause(url: URL) { 73 | guard let task = task(for: url) else { 74 | fatalError("There should be a task") 75 | } 76 | task.suspend() 77 | modifyState(for: task) { 78 | $0.state = .pausedByUser 79 | } 80 | } 81 | 82 | // delegate 83 | 84 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 85 | modifyState(for: downloadTask) { 86 | $0.progress = 1 87 | $0.state = .finished 88 | } 89 | let state = states[downloadTask]! 90 | saveDownload?(state.url, location) 91 | states[downloadTask] = nil 92 | } 93 | 94 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 95 | let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) 96 | modifyState(for: downloadTask) { state in 97 | state.progress = progress 98 | state.state = .inProgress 99 | } 100 | } 101 | 102 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 103 | guard let _ = error else { return } 104 | modifyState(for: task) { state in 105 | state.state = .waitingForConnection 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /SwiftTalk/Environment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Environment.swift 3 | // Videos 4 | // 5 | // Created by Florian Kugler on 10-10-2016. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Environment { 12 | var baseURL = URL(string: "https://talk.objc.io")! 13 | 14 | static let current = Environment() 15 | } 16 | -------------------------------------------------------------------------------- /SwiftTalk/EpisodeCell.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class EpisodeCell: UITableViewCell { 4 | fileprivate var titleLabel = UILabel() 5 | fileprivate var badgeLabel = UILabel() // shown in inverted colors 6 | fileprivate var detailLabel1 = UILabel() 7 | fileprivate var detailLabel2 = UILabel() 8 | 9 | override public init(style: UITableViewCellStyle, reuseIdentifier: String?) { 10 | super.init(style: .subtitle, reuseIdentifier: reuseIdentifier) 11 | contentView.addSubview(titleLabel) 12 | contentView.addSubview(badgeLabel) 13 | contentView.addSubview(detailLabel1) 14 | contentView.addSubview(detailLabel2) 15 | setupLayout() 16 | configureSubviews() 17 | } 18 | 19 | required public init?(coder aDecoder: NSCoder) { 20 | fatalError("init(coder:) has not been implemented") 21 | } 22 | 23 | private func setupLayout() { 24 | titleLabel.translatesAutoresizingMaskIntoConstraints = false 25 | titleLabel.topAnchor.constrainEqual(contentView.layoutMarginsGuide.topAnchor) 26 | titleLabel.leadingAnchor.constrainEqual(contentView.layoutMarginsGuide.leadingAnchor) 27 | titleLabel.trailingAnchor.constrainEqual(contentView.layoutMarginsGuide.trailingAnchor) 28 | 29 | badgeLabel.translatesAutoresizingMaskIntoConstraints = false 30 | badgeLabel.centerYAnchor.constrainEqual(detailLabel1.centerYAnchor) 31 | badgeLabel.leadingAnchor.constrainEqual(titleLabel.leadingAnchor) 32 | badgeLabel.bottomAnchor.constrainEqual(contentView.layoutMarginsGuide.bottomAnchor) 33 | badgeLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 100).isActive = true 34 | 35 | detailLabel1.translatesAutoresizingMaskIntoConstraints = false 36 | detailLabel1.topAnchor.constrainEqual(titleLabel.bottomAnchor, constant: 8) 37 | detailLabel1.leadingAnchor.constrainEqual(badgeLabel.trailingAnchor, constant: 16) 38 | detailLabel1.bottomAnchor.constrainEqual(contentView.layoutMarginsGuide.bottomAnchor) 39 | 40 | detailLabel2.translatesAutoresizingMaskIntoConstraints = false 41 | detailLabel2.centerYAnchor.constrainEqual(detailLabel1.centerYAnchor) 42 | detailLabel2.trailingAnchor.constrainEqual(titleLabel.trailingAnchor) 43 | } 44 | 45 | private func configureSubviews() { 46 | contentView.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16) 47 | titleLabel.numberOfLines = 0 48 | titleLabel.font = UIFont.preferredFont(forTextStyle: .title3) 49 | detailLabel1.font = UIFont.preferredFont(forTextStyle: .headline) 50 | detailLabel2.font = UIFont.preferredFont(forTextStyle: .headline) 51 | badgeLabel.font = UIFont.preferredFont(forTextStyle: .subheadline) 52 | badgeLabel.textAlignment = .center 53 | badgeLabel.textColor = .white 54 | badgeLabel.backgroundColor = .black 55 | badgeLabel.clipsToBounds = true 56 | badgeLabel.layer.cornerRadius = 10 57 | } 58 | } 59 | 60 | extension EpisodeCell { 61 | public func configure(viewModel: EpisodeViewModel) { 62 | titleLabel.text = viewModel.episode.title 63 | badgeLabel.text = viewModel.episode.subscriptionOnly ? "SUB" : "FREE" 64 | detailLabel1.text = viewModel.releaseDate 65 | detailLabel2.text = viewModel.duration 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /SwiftTalk/EpisodeDetailViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | final public class EpisodeDetailViewController: UIViewController { 4 | let viewModel: EpisodeViewModel 5 | var content: [ContentElement] = [] 6 | let scrollView = UIScrollView() 7 | let stack = UIStackView() 8 | let thumbnail = UIImageView() 9 | 10 | public init(viewModel: EpisodeViewModel, didTapPlay: @escaping (Episode) -> ()) { 11 | self.viewModel = viewModel 12 | super.init(nibName: nil, bundle: nil) 13 | title = viewModel.episode.title 14 | let components: [ContentElement?] = [ 15 | ContentElement(text: viewModel.episode.title, style: .title2, alignment: .center), 16 | .custom(thumbnail), 17 | ContentElement(text: viewModel.synopsis), 18 | .custom(UIStackView(axis: .horizontal, spacing: 16, content: [ 19 | ContentElement(text: viewModel.subscriptionOnlyText), 20 | ContentElement(text: viewModel.releaseDate, alignment: .center), 21 | ContentElement(text: viewModel.duration, alignment: .right), 22 | ])), 23 | .button(title: viewModel.playButtonTitle, callback: { didTapPlay(viewModel.episode) }), 24 | viewModel.isLoginButtonVisible ? .button(title: "Login", callback: { [unowned self] in self.presentAlert(message: "Not implemented yet.") }) : nil 25 | ] 26 | content = components.flatMap { $0 } 27 | } 28 | 29 | required public init?(coder aDecoder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | override public func loadView() { 34 | view = UIView() 35 | view.addSubview(scrollView) 36 | scrollView.addSubview(stack) 37 | 38 | scrollView.translatesAutoresizingMaskIntoConstraints = false 39 | scrollView.topAnchor.constrainEqual(view.layoutMarginsGuide.topAnchor) 40 | scrollView.leadingAnchor.constrainEqual(view.layoutMarginsGuide.leadingAnchor) 41 | scrollView.trailingAnchor.constrainEqual(view.layoutMarginsGuide.trailingAnchor) 42 | scrollView.bottomAnchor.constrainEqual(view.layoutMarginsGuide.bottomAnchor) 43 | 44 | stack.translatesAutoresizingMaskIntoConstraints = false 45 | stack.topAnchor.constrainEqual(scrollView.layoutMarginsGuide.topAnchor) 46 | stack.leadingAnchor.constrainEqual(scrollView.layoutMarginsGuide.leadingAnchor) 47 | stack.trailingAnchor.constrainEqual(scrollView.layoutMarginsGuide.trailingAnchor) 48 | 49 | stack.axis = .vertical 50 | stack.spacing = 40 51 | stack.layoutMargins = UIEdgeInsets(top: 60, left: 500, bottom: 200, right: 500) 52 | stack.isLayoutMarginsRelativeArrangement = true 53 | 54 | thumbnail.contentMode = .scaleAspectFit 55 | thumbnail.heightAnchor.constraint(equalToConstant: 400).isActive = true 56 | thumbnail.image = UIImage(named: "placeholder") 57 | viewModel.loadThumbnail { [weak self] image in self?.thumbnail.image = image } 58 | 59 | for element in content { 60 | stack.addArrangedSubview(element.view) 61 | } 62 | } 63 | 64 | public override func viewDidLayoutSubviews() { 65 | super.viewDidLayoutSubviews() 66 | scrollView.contentSize = CGSize(width: view.bounds.maxX, height: max(view.bounds.maxY, stack.frame.maxY)) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /SwiftTalk/EpisodeModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Model.swift 3 | // Videos 4 | // 5 | // Created by Florian on 13/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public struct Episode { 12 | public var id: String 13 | public var title: String 14 | public var season: Int 15 | public var number: Int 16 | public var subscriptionOnly: Bool 17 | public var synopsis: String? 18 | public var text: String? 19 | // TODO naming? 20 | public var lastUpdate: Date 21 | public var release: Date? 22 | public var thumbnailURL: URL 23 | public var mediaURL: URL 24 | public var duration: Measurement? 25 | 26 | public init(id: String, title: String, season: Int, number: Int, subscriptionOnly: Bool, synopsis: String, text: String, lastUpdate: Date, release: Date? , thumbnailURL: URL, mediaURL: URL, duration: Measurement?) { 27 | self.id = id 28 | self.title = title 29 | self.season = season 30 | self.number = number 31 | self.subscriptionOnly = subscriptionOnly 32 | self.synopsis = synopsis 33 | self.text = text 34 | self.lastUpdate = lastUpdate 35 | self.release = release 36 | self.thumbnailURL = thumbnailURL 37 | self.mediaURL = mediaURL 38 | self.duration = duration 39 | } 40 | } 41 | 42 | public typealias JSONDictionary = [String: Any] 43 | 44 | extension Episode { 45 | public init?(json: JSONDictionary) { 46 | guard let id = json["id"] as? String, 47 | let title = json["title"] as? String, 48 | let season = json["season"] as? Int, 49 | let number = json["number"] as? Int, 50 | let subscriptionOnly = json["subscription_only"] as? Bool, 51 | let lastUpdateTimestamp = json["updated_at"] as? TimeInterval, 52 | let thumbnail = json["poster_url"] as? String, let thumbnailURL = URL(string: thumbnail), 53 | let media = json["media_url"] as? String, let mediaURL = URL(string: media) 54 | else { return nil } 55 | 56 | self.id = id 57 | self.title = title 58 | self.season = season 59 | self.number = number 60 | self.subscriptionOnly = subscriptionOnly 61 | self.lastUpdate = Date(timeIntervalSince1970: lastUpdateTimestamp) 62 | self.thumbnailURL = thumbnailURL 63 | self.mediaURL = mediaURL 64 | self.synopsis = json["synopsis"] as? String 65 | self.text = json["transcript"] as? String 66 | if let releaseTimestamp = json["released_at"] as? TimeInterval { 67 | self.release = Date(timeIntervalSince1970: releaseTimestamp) 68 | } 69 | if let duration = json["media_duration"] as? TimeInterval { 70 | self.duration = Measurement(value: duration, unit: .seconds) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /SwiftTalk/EpisodeViewModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import UIKit 3 | 4 | public struct EpisodeViewModel { 5 | public var episode: Episode 6 | public var loggedIn: Bool 7 | // TODO: Is it a good idea to inject the webservice into the view model? 8 | public let webservice: CachedWebservice 9 | 10 | public var synopsis: String { return episode.synopsis ?? "" } 11 | 12 | public var releaseDate: String { 13 | return releaseDateFormatter.string(from: episode.release ?? episode.lastUpdate) 14 | } 15 | 16 | public var duration: String { 17 | guard let duration = episode.duration else { return "" } 18 | return durationFormatter.string(from: duration) 19 | } 20 | 21 | public var subscriptionOnlyText: String { 22 | return episode.subscriptionOnly ? "Subscribers Only" : "Free" 23 | } 24 | 25 | public var playButtonTitle: String { 26 | if episode.subscriptionOnly { 27 | return loggedIn ? "Play" : "Preview" 28 | } else { 29 | return "Play" 30 | } 31 | } 32 | 33 | public var isLoginButtonVisible: Bool { return episode.subscriptionOnly && !loggedIn } 34 | 35 | public func loadThumbnail(_ done: @escaping (UIImage) -> ()) { 36 | DispatchQueue.main.async { 37 | self.webservice.load(self.episode.thumbnail, skipCache: false) { response in 38 | if let image = response.value { 39 | done(image) 40 | } 41 | } 42 | } 43 | } 44 | 45 | private var releaseDateFormatter: DateFormatter { 46 | let formatter = DateFormatter() 47 | formatter.dateStyle = .medium 48 | formatter.timeStyle = .none 49 | return formatter 50 | } 51 | 52 | /// - TODO: MeasurmentFormatter doesn't seem to support mixed-unit formats like "21 m 30 s"; it always displays "21.5 min", so we set the fraction digits to 0. This is fine for videos under 1 hour, but should we ever have videos longer than 1 hour, we'd probably have to roll a custom solution. 53 | private var durationFormatter: MeasurementFormatter { 54 | let formatter = MeasurementFormatter() 55 | formatter.unitOptions = [.naturalScale] 56 | formatter.numberFormatter.maximumFractionDigits = 0 57 | return formatter 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /SwiftTalk/EpisodesListViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class ReadableContentViewController: UIViewController { 4 | private let child: UIViewController 5 | 6 | public init(_ child: UIViewController) { 7 | self.child = child 8 | super.init(nibName: nil, bundle: nil) 9 | self.title = child.title 10 | } 11 | 12 | required public init?(coder aDecoder: NSCoder) { 13 | fatalError("init(coder:) has not been implemented") 14 | } 15 | 16 | public override func viewDidLoad() { 17 | super.viewDidLoad() 18 | addChildViewController(child) 19 | view.addSubview(child.view) 20 | child.view.translatesAutoresizingMaskIntoConstraints = false 21 | child.view.constrainEdges(to: view.readableContentGuide) 22 | child.didMove(toParentViewController: self) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /SwiftTalk/Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Extensions.swift 3 | // Videos 4 | // 5 | // Created by Florian on 07/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import AVFoundation 10 | import UIKit 11 | 12 | extension URL { 13 | public subscript(queryItem name: String) -> String? { 14 | get { 15 | guard let items = URLComponents(url: self, resolvingAgainstBaseURL: false)?.queryItems, 16 | let index = items.index(where: { $0.name == name }) else { return nil } 17 | return items[index].value 18 | } 19 | set { 20 | guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else { 21 | // Silently fail if we can't convert the URL to URLComponents 22 | return 23 | } 24 | let newItem = URLQueryItem(name: name, value: newValue) 25 | if let index = components.queryItems?.index(where: { $0.name == name }) { 26 | // Found a query item with this name 27 | components.queryItems?[index] = newItem 28 | } else if components.queryItems != nil { 29 | // Query item with this name not found, but queryItems array exists 30 | components.queryItems?.append(newItem) 31 | } else { 32 | // queryItems array doesn't exist yet 33 | components.queryItems = [newItem] 34 | } 35 | if let newURL = components.url { 36 | self = newURL 37 | } 38 | } 39 | } 40 | } 41 | 42 | extension UIViewController { 43 | public func addChildViewController(_ childViewController: UIViewController, to stackView: UIStackView) { 44 | assert(stackView.isDescendant(of: view)) 45 | addChildViewController(childViewController) 46 | stackView.addArrangedSubview(childViewController.view) 47 | childViewController.didMove(toParentViewController: self) 48 | } 49 | } 50 | 51 | extension UIView { 52 | public func constrainEqual(_ attribute: NSLayoutAttribute, to: AnyObject, multiplier: CGFloat = 1, constant: CGFloat = 0) { 53 | constrainEqual(attribute, to: to, attribute, multiplier: multiplier, constant: constant) 54 | } 55 | 56 | public func constrainEqual(_ attribute: NSLayoutAttribute, to: AnyObject, _ toAttribute: NSLayoutAttribute, multiplier: CGFloat = 1, constant: CGFloat = 0) { 57 | NSLayoutConstraint.activate([ 58 | NSLayoutConstraint(item: self, attribute: attribute, relatedBy: .equal, toItem: to, attribute: toAttribute, multiplier: multiplier, constant: constant) 59 | ] 60 | ) 61 | } 62 | 63 | public func constrainEdges(to other: UILayoutGuide) { 64 | topAnchor.constrainEqual(other.topAnchor) 65 | bottomAnchor.constrainEqual(other.bottomAnchor) 66 | leadingAnchor.constrainEqual(other.leadingAnchor) 67 | trailingAnchor.constrainEqual(other.trailingAnchor) 68 | } 69 | 70 | public func constrainEdges(toMarginOf view: UIView) { 71 | constrainEqual(.top, to: view, .topMargin) 72 | constrainEqual(.leading, to: view, .leadingMargin) 73 | constrainEqual(.trailing, to: view, .trailingMargin) 74 | constrainEqual(.bottom, to: view, .bottomMargin) 75 | } 76 | 77 | /// If the `view` is nil, we take the superview. 78 | public func center(inView view: UIView? = nil) { 79 | guard let container = view ?? self.superview else { fatalError() } 80 | centerXAnchor.constrainEqual(container.centerXAnchor) 81 | centerYAnchor.constrainEqual(container.centerYAnchor) 82 | } 83 | 84 | public var debugBorder: UIColor? { 85 | get { return layer.borderColor.map { UIColor(cgColor: $0) } } 86 | set { 87 | layer.borderColor = newValue?.cgColor 88 | layer.borderWidth = newValue != nil ? 1 : 0 89 | } 90 | } 91 | 92 | public static func activateDebugBorders(_ views: [UIView]) { 93 | let colors: [UIColor] = [.magenta, .orange, .green, .blue, .red] 94 | for (view, color) in zip(views, colors.cycled()) { 95 | view.debugBorder = color 96 | } 97 | } 98 | } 99 | 100 | extension NSLayoutAnchor { 101 | func constrainEqual(_ anchor: NSLayoutAnchor, constant: CGFloat = 0) { 102 | let constraint = self.constraint(equalTo: anchor, constant: constant) 103 | constraint.isActive = true 104 | } 105 | } 106 | 107 | extension NSAttributedString { 108 | public var mutable: NSMutableAttributedString { 109 | return mutableCopy() as! NSMutableAttributedString 110 | } 111 | 112 | public var range: NSRange { 113 | return NSRange(location: 0, length: (string as NSString).length) 114 | } 115 | 116 | public convenience init(string: String, alignment: NSTextAlignment) { 117 | let style = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle 118 | style.alignment = alignment 119 | self.init(string: string, attributes: [NSParagraphStyleAttributeName: style]) 120 | } 121 | } 122 | 123 | public func +(lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString { 124 | let result = lhs.mutable 125 | result.append(rhs) 126 | return result 127 | } 128 | 129 | extension Array where Element: NSAttributedString { 130 | public func join(_ separator: String) -> NSAttributedString { 131 | return join(NSAttributedString(string: separator)) 132 | } 133 | 134 | public func join(_ separator: NSAttributedString = NSAttributedString()) -> NSAttributedString { 135 | guard !isEmpty else { return NSAttributedString() } 136 | 137 | let result = self[0].mutable 138 | for string in dropFirst() { 139 | result.append(separator) 140 | result.append(string) 141 | } 142 | return result 143 | } 144 | } 145 | 146 | extension Sequence { 147 | public func failingFlatMap(_ transform: (Self.Iterator.Element) throws -> T?) rethrows -> [T]? { 148 | var result: [T] = [] 149 | for element in self { 150 | guard let transformed = try transform(element) else { return nil } 151 | result.append(transformed) 152 | } 153 | return result 154 | } 155 | 156 | /// Returns a sequence that repeatedly cycles through the elements of `self`. 157 | public func cycled() -> AnySequence { 158 | return AnySequence { _ -> AnyIterator in 159 | var iterator = self.makeIterator() 160 | return AnyIterator { 161 | if let next = iterator.next() { 162 | return next 163 | } else { 164 | iterator = self.makeIterator() 165 | return iterator.next() 166 | } 167 | } 168 | } 169 | } 170 | } 171 | 172 | public func mainQueue(_ block: @escaping () -> ()) { 173 | DispatchQueue.main.async(execute: block) 174 | } 175 | 176 | 177 | extension Data { 178 | public var hexadecimalString: String { 179 | return map { String(format: "%02x", $0) }.joined(separator: "") 180 | } 181 | 182 | func sha1() -> Data { 183 | var digestData = Data(count: Int(CC_SHA1_DIGEST_LENGTH)) 184 | _ = digestData.withUnsafeMutableBytes { digestBytes in 185 | self.withUnsafeBytes { 186 | _ = CC_SHA1($0, CC_LONG(self.count), digestBytes) 187 | } 188 | } 189 | return digestData 190 | } 191 | } 192 | 193 | extension String { 194 | func sha1() -> String { 195 | return self.data(using: .utf8)!.sha1().hexadecimalString 196 | } 197 | } 198 | 199 | public extension AVMetadataItem { 200 | /// - parameter language: The default is "und" ("undefined"), which is the fallback value for all languages that don't have a specific value set. 201 | /// - note: An initializer would be better for this, but it's not possible to write a "factory" initializer that internally instantiates a AVMutableMetadataItem (cannot assign to self). 202 | static func item(identifier: String, value: String?, language: String = "und") -> AVMetadataItem { 203 | let item = AVMutableMetadataItem() 204 | item.identifier = identifier 205 | item.value = value as NSString? 206 | item.extendedLanguageTag = language 207 | return item.copy() as! AVMetadataItem 208 | } 209 | 210 | static func item(identifier: String, image: UIImage?, language: String = "und") -> AVMetadataItem { 211 | let item = AVMutableMetadataItem() 212 | item.identifier = identifier 213 | if let image = image { 214 | item.value = UIImagePNGRepresentation(image) as NSData? 215 | } else { 216 | item.value = nil 217 | } 218 | item.dataType = kCMMetadataBaseDataType_PNG as String 219 | item.extendedLanguageTag = language 220 | return item.copy() as! AVMetadataItem 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /SwiftTalk/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIRequiredDeviceCapabilities 24 | 25 | arm64 26 | 27 | UIUserInterfaceStyle 28 | Automatic 29 | 30 | 31 | -------------------------------------------------------------------------------- /SwiftTalk/Keychain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keychain.swift 3 | // Videos 4 | // 5 | // Created by Chris Eidhof on 07/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Security 11 | 12 | private func throwIfNotZero(_ status: OSStatus) throws { 13 | guard status != 0 else { return } 14 | throw KeychainError.keychainError(status: status) 15 | } 16 | 17 | 18 | public enum KeychainError: Error { 19 | case invalidData 20 | case keychainError(status: OSStatus) 21 | } 22 | 23 | extension Dictionary { 24 | public func adding(key: Key, value: Value) -> Dictionary { 25 | var copy = self 26 | copy[key] = value 27 | return copy 28 | } 29 | } 30 | 31 | public final class KeychainItem { 32 | private let account: String 33 | 34 | public init(account: String) { 35 | self.account = account 36 | } 37 | 38 | private var baseDictionary: [String:AnyObject] { 39 | return [ 40 | kSecClass as String: kSecClassGenericPassword, 41 | kSecAttrAccount as String: account as AnyObject 42 | ] 43 | } 44 | 45 | private var query: [String:AnyObject] { 46 | return baseDictionary.adding(key: kSecMatchLimit as String, value: kSecMatchLimitOne) 47 | } 48 | 49 | public func set(_ secret: String) throws { 50 | if try read() == nil { 51 | try add(secret) 52 | } else { 53 | try update(secret) 54 | } 55 | } 56 | 57 | public func delete() throws { 58 | // SecItemDelete seems to fail with errSecItemNotFound if the item does not exist in the keychain. Is this expected behavior? 59 | let status = SecItemDelete(baseDictionary as CFDictionary) 60 | guard status != errSecItemNotFound else { return } 61 | try throwIfNotZero(status) 62 | } 63 | 64 | public func read() throws -> String? { 65 | let query = self.query.adding(key: kSecReturnData as String, value: true as AnyObject) 66 | var result: AnyObject? = nil 67 | let status = SecItemCopyMatching(query as CFDictionary, &result) 68 | guard status != errSecItemNotFound else { return nil } 69 | try throwIfNotZero(status) 70 | guard let data = result as? Data, let string = String(data: data, encoding: String.Encoding.utf8) else { 71 | throw KeychainError.invalidData 72 | } 73 | return string 74 | } 75 | 76 | private func update(_ secret: String) throws { 77 | let dictionary: [String:AnyObject] = [ 78 | kSecValueData as String: secret.data(using: String.Encoding.utf8)! as AnyObject 79 | ] 80 | try throwIfNotZero(SecItemUpdate(baseDictionary as CFDictionary, dictionary as CFDictionary)) 81 | } 82 | 83 | private func add(_ secret: String) throws { 84 | let dictionary = baseDictionary.adding(key: kSecValueData as String, value: secret.data(using: String.Encoding.utf8)! as AnyObject) 85 | try throwIfNotZero(SecItemAdd(dictionary as CFDictionary, nil)) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /SwiftTalk/Login.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class Login { 4 | public enum State { 5 | /// User is signed out, has not started the sign in flow yet. 6 | case signedOut 7 | /// Signed out. Begun the sign in flow, requesting an auth code from the server. 8 | case requestingAuthCode 9 | /// Signed out. Error requesting auth code from server. 10 | case requestingAuthCodeFailed(Error) 11 | /// Signed out. Server returned an auth code that the user must now enter in their web browser on another device to sign in on this device. 12 | case receivedAuthCode(AuthCode) 13 | /// User is signed in. 14 | case signedIn(AuthToken) 15 | } 16 | 17 | public private(set) var state: State { 18 | didSet { 19 | authenticationToken = state.authToken 20 | screen.state = state 21 | if (state != oldValue) { 22 | stateDidChange?(state) 23 | } 24 | } 25 | } 26 | /// Called by the view controller when the signed in/signed out state changes. 27 | public var stateDidChange: ((State) -> ())? 28 | public let screen: LoginViewController 29 | private let webservice: Webservice 30 | // TODO: Move to Environment? 31 | private let keychainToken: KeychainItem 32 | 33 | public init(webservice: Webservice) { 34 | // TODO: Verify that this is the key we want to use. 35 | // TODO: Investigate app groups/shared keychains to prepare for keychain sharing with a future iOS app. 36 | keychainToken = KeychainItem(account: "io.objc.videos.token") 37 | 38 | // TODO: Encapsulate this in a way that works with the initializer rules 39 | let initialState: State 40 | if let token = (try? keychainToken.read()).flatMap({ $0 }) { 41 | initialState = .signedIn(AuthToken(token)) 42 | } else { 43 | initialState = .signedOut 44 | } 45 | 46 | self.state = initialState 47 | self.webservice = webservice 48 | screen = LoginViewController(title: "Account", state: initialState) 49 | screen.requestAuthCode = { [unowned self] in self.requestAuthCode() } 50 | screen.verifyAuthCode = { [unowned self] authCode in self.verifyUserHasRegisteredAuthCode(authCode: authCode) } 51 | screen.signOut = { [unowned self] in self.signOut() } 52 | } 53 | 54 | public var authenticated: Bool { 55 | return state.authenticated 56 | } 57 | 58 | // TODO: Remove duplication between Login.State and this state. 59 | // This one handles the keychain while Login.State basically handles the rest. 60 | public private(set) var authenticationToken: AuthToken? { 61 | get { 62 | guard let token = (try? keychainToken.read()).flatMap({ $0 }) else { return nil } 63 | return AuthToken(token) 64 | } 65 | set { 66 | // These `try!`s should never fail, if they do, it's a programmer error and we trap. 67 | do { 68 | if let token = newValue { 69 | try self.keychainToken.set(token.value) 70 | } else { 71 | try self.keychainToken.delete() 72 | } 73 | } catch { 74 | print("Error: \(error)") 75 | } 76 | } 77 | } 78 | 79 | private func requestAuthCode() { 80 | state = .requestingAuthCode 81 | webservice.load(AuthCode.requestAuthCode) { [unowned self] result in 82 | switch result { 83 | case .success(let authCode): 84 | self.state = .receivedAuthCode(authCode) 85 | case .error(let error): 86 | self.state = .requestingAuthCodeFailed(error) 87 | } 88 | } 89 | } 90 | 91 | private func verifyUserHasRegisteredAuthCode(authCode: AuthCode) { 92 | webservice.load(authCode.verifyAuthCode) { [unowned self] result in 93 | switch result { 94 | case .success(let response): 95 | self.state = .signedIn(response.token) 96 | case .error(let error): 97 | // TODO: Don't show an error once continuous polling is implemented. 98 | self.screen.presentAlert(message: "Could not verify your auth code. Please enter it in your web browser and try again. \(error.localizedDescription)") 99 | } 100 | } 101 | } 102 | 103 | private func signOut() { 104 | state = .signedOut 105 | } 106 | } 107 | 108 | extension Login.State { 109 | public var authenticated: Bool { 110 | if case .signedIn(_) = self { return true } 111 | else { return false } 112 | } 113 | 114 | public var authToken: AuthToken? { 115 | switch self { 116 | case .signedOut, 117 | .requestingAuthCode, 118 | .requestingAuthCodeFailed(_), 119 | .receivedAuthCode(_): 120 | return nil 121 | case .signedIn(let token): 122 | return token 123 | } 124 | } 125 | } 126 | 127 | extension Login.State: Equatable { 128 | public static func ==(lhs: Login.State, rhs: Login.State) -> Bool { 129 | switch (lhs, rhs) { 130 | case (.signedOut, .signedOut): 131 | return true 132 | case (.requestingAuthCode, .requestingAuthCode): 133 | return true 134 | case (.requestingAuthCodeFailed(_), .requestingAuthCodeFailed(_)): 135 | // Ignoring the associated Error values. 136 | return true 137 | case (.receivedAuthCode(let left), receivedAuthCode(let right)): 138 | return left == right 139 | case (.signedIn(let left), .signedIn(let right)): 140 | return left == right 141 | case (.signedOut, _), 142 | (.requestingAuthCode, _), 143 | (.requestingAuthCodeFailed, _), 144 | (.receivedAuthCode(_), _), 145 | (.signedIn(_), _): 146 | return false 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /SwiftTalk/LoginModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct AuthToken { 4 | var value: String 5 | public init(_ value: String) { 6 | self.value = value 7 | } 8 | } 9 | 10 | extension AuthToken: ExpressibleByStringLiteral { 11 | public init(stringLiteral value: String) { self.init(value) } 12 | public init(unicodeScalarLiteral value: String) { self.init(value) } 13 | public init(extendedGraphemeClusterLiteral value: String) { self.init(value) } 14 | } 15 | 16 | extension AuthToken: Equatable { 17 | public static func ==(lhs: AuthToken, rhs: AuthToken) -> Bool { 18 | assert(dump(lhs) == dump(rhs)) 19 | return lhs.value == rhs.value 20 | } 21 | } 22 | 23 | public struct AuthCode { 24 | public var code: String 25 | // TODO: Should this be an AuthToken? It's never really used as such. 26 | public var token: String 27 | } 28 | 29 | extension AuthCode: Equatable { 30 | public static func ==(lhs: AuthCode, rhs: AuthCode) -> Bool { 31 | return lhs.code == rhs.code && lhs.token == rhs.token 32 | } 33 | } 34 | 35 | extension AuthCode { 36 | public init?(json: JSONDictionary) { 37 | guard let code = json["code"] as? String, 38 | let token = json["token"] as? String 39 | else { return nil } 40 | self.code = code 41 | self.token = token 42 | } 43 | } 44 | 45 | extension AuthCode { 46 | public static var requestAuthCode: Resource = try! Resource( 47 | url: Environment.current.baseURL.appendingPathComponent("tokens"), 48 | method: .post(payload: nil), 49 | parseJSON: { ($0 as? JSONDictionary).flatMap(AuthCode.init(json:)) } 50 | ) 51 | 52 | public var verifyAuthCode: Resource { 53 | var url = Environment.current.baseURL.appendingPathComponent("tokens/poll") 54 | url[queryItem: "token"] = token 55 | return Resource( 56 | url: url, 57 | parseJSON: { ($0 as? JSONDictionary).flatMap(AuthResponse.init(json:)) } 58 | ) 59 | } 60 | } 61 | 62 | public struct AuthResponse { 63 | public var token: AuthToken 64 | } 65 | 66 | extension AuthResponse: Equatable { 67 | public static func ==(lhs: AuthResponse, rhs: AuthResponse) -> Bool { 68 | return lhs.token == rhs.token 69 | } 70 | } 71 | 72 | extension AuthResponse { 73 | public init?(json: JSONDictionary) { 74 | guard let token = json["token"] as? String else { return nil } 75 | self.token = AuthToken(token) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /SwiftTalk/LoginViewController.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | public final class LoginViewController: UIViewController { 4 | public var state: Login.State { 5 | didSet { 6 | guard state != oldValue else { return } 7 | DispatchQueue.main.async { self.updateUI() } 8 | } 9 | } 10 | 11 | // The view controller calls these functions to notify its owner of certain user interaction events. 12 | // TODO: Not all functions are valid for all states. Integrate into State? Or convert into delegate? 13 | /// User requests an auth code. 14 | public var requestAuthCode: (() -> ())? 15 | /// User asks to verify the registration (after they have entered the auth code in the web browser). 16 | /// - TODO: this should be automatic, without requiring user interaction (polling) 17 | public var verifyAuthCode: ((AuthCode) -> ())? 18 | /// User wants to sign out. 19 | public var signOut: (() -> ())? 20 | 21 | private let stack = UIStackView() 22 | 23 | public init(title: String, state: Login.State) { 24 | self.state = state 25 | super.init(nibName: nil, bundle: nil) 26 | self.title = title 27 | } 28 | 29 | required public init?(coder decoder: NSCoder) { 30 | fatalError("init(coder:) has not been implemented") 31 | } 32 | 33 | public override func loadView() { 34 | view = UIView() 35 | view.addSubview(stack) 36 | 37 | stack.translatesAutoresizingMaskIntoConstraints = false 38 | stack.axis = .vertical 39 | stack.spacing = 40 40 | stack.layoutMargins = UIEdgeInsets(top: 100, left: 500, bottom: 100, right: 500) 41 | stack.isLayoutMarginsRelativeArrangement = true 42 | 43 | stack.centerXAnchor.constrainEqual(view.layoutMarginsGuide.centerXAnchor) 44 | stack.centerYAnchor.constrainEqual(view.layoutMarginsGuide.centerYAnchor) 45 | stack.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor).isActive = true 46 | stack.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor).isActive = true 47 | stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 600).isActive = true 48 | 49 | updateUI() 50 | } 51 | 52 | private func updateUI() { 53 | let rootView = userInterface(for: state) 54 | for view in stack.arrangedSubviews { 55 | view.removeFromSuperview() 56 | } 57 | stack.addArrangedSubview(rootView) 58 | } 59 | 60 | private func userInterface(for state: Login.State) -> UIView { 61 | switch state { 62 | case .signedOut: 63 | return UIStackView(content: [ 64 | .button(title: "Sign In", callback: { [unowned self] in self.requestAuthCode?() }) 65 | ]) 66 | case .requestingAuthCode: 67 | let activityIndicator = UIActivityIndicatorView() 68 | activityIndicator.activityIndicatorViewStyle = .whiteLarge 69 | activityIndicator.startAnimating() 70 | return UIStackView(content: [ 71 | ContentElement(text: "Requesting authentication code", alignment: .center), 72 | .custom(activityIndicator), 73 | ]) 74 | case .requestingAuthCodeFailed(let error): 75 | return UIStackView(content: [ 76 | ContentElement(text: "An error occurred. Please try again later. \(error.localizedDescription)", alignment: .center), 77 | .button(title: "Retry", callback: { [unowned self] in self.requestAuthCode?() }), 78 | ]) 79 | case .receivedAuthCode(let authCode): 80 | return UIStackView(content: [ 81 | ContentElement(text: "Your auth code is:", style: .callout, alignment: .center), 82 | ContentElement(text: authCode.code, style: .headline, alignment: .center), 83 | ContentElement(text: "Go to https://talk.objc.io/verify on your mobile device or computer and enter this code.", style: .callout, alignment: .center), 84 | .button(title: "I have entered the code", callback: { [unowned self] in self.verifyAuthCode?(authCode) }), 85 | .button(title: "Request a new code", callback: { [unowned self] in self.requestAuthCode?() }), 86 | ]) 87 | case .signedIn(_): 88 | return UIStackView(content: [ 89 | ContentElement(text: "You are signed in.", style: .callout, alignment: .center), 90 | .button(title: "Sign Out", callback: { self.signOut?() }) 91 | ]) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /SwiftTalk/Notifications.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct NotificationDescriptor { 4 | let name: Notification.Name 5 | let convert: (Notification) -> A 6 | } 7 | 8 | public class NotificationToken { 9 | let token: NSObjectProtocol 10 | let center: NotificationCenter 11 | init(token: NSObjectProtocol, center: NotificationCenter) { 12 | self.token = token 13 | self.center = center 14 | } 15 | 16 | deinit { 17 | center.removeObserver(token) 18 | } 19 | } 20 | 21 | extension NotificationCenter { 22 | func addObserver(descriptor: NotificationDescriptor, object: Any? = nil, queue: OperationQueue? = nil, using block: @escaping (A) -> ()) -> NotificationToken { 23 | let token = addObserver(forName: descriptor.name, object: nil, queue: nil, using: { note in 24 | block(descriptor.convert(note)) 25 | }) 26 | return NotificationToken(token: token, center: self) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /SwiftTalk/Resource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Resource.swift 3 | // Videos 4 | // 5 | // Created by Florian on 13/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum HttpMethod { 12 | case get 13 | case post(payload: A?) 14 | 15 | public var method: String { 16 | switch self { 17 | case .get: return "GET" 18 | case .post: return "POST" 19 | } 20 | } 21 | 22 | public func map(f: (A) throws -> B) rethrows -> HttpMethod { 23 | switch self { 24 | case .get: return .get 25 | case .post(let payload): 26 | guard let payload = payload else { return .post(payload: nil) } 27 | return .post(payload: try f(payload)) 28 | } 29 | } 30 | } 31 | 32 | public struct Resource { 33 | public var url: URL 34 | public var parse: (Data) -> A? 35 | public var method: HttpMethod = .get 36 | } 37 | 38 | extension Resource { 39 | public var urlRequest: URLRequest { 40 | var request = URLRequest(url: url) 41 | request.httpMethod = method.method 42 | if case .post(let payload) = method { 43 | request.httpBody = payload 44 | } 45 | return request 46 | } 47 | } 48 | 49 | extension Resource { 50 | 51 | public init(url: URL, parseJSON: @escaping (Any) -> A?) { 52 | self.url = url 53 | self.method = .get 54 | self.parse = { data in 55 | let json = try? JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions()) 56 | return json.flatMap(parseJSON) 57 | } 58 | } 59 | 60 | public init(url: URL, method: HttpMethod, parseJSON: @escaping (Any) -> A?) throws { 61 | self.url = url 62 | self.method = try method.map { jsonObject in 63 | try JSONSerialization.data(withJSONObject: jsonObject, options: JSONSerialization.WritingOptions()) 64 | } 65 | self.parse = { data in 66 | let json = try? JSONSerialization.jsonObject(with: data, options: JSONSerialization.ReadingOptions()) 67 | return json.flatMap(parseJSON) 68 | } 69 | } 70 | } 71 | 72 | extension Resource where A: RangeReplaceableCollection { 73 | public init(url: URL, method: HttpMethod = .get, parseElement: @escaping (JSONDictionary) -> A.Iterator.Element?) throws { 74 | self = try Resource(url: url, method: method, parseJSON: { json in 75 | guard let jsonDicts = json as? [JSONDictionary], 76 | let result = jsonDicts.failingFlatMap(parseElement) else { return nil } 77 | return A(result) 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /SwiftTalk/Screens.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import AVKit 3 | import UIKit 4 | 5 | public final class Screens { 6 | fileprivate let webservice: CachedWebservice 7 | 8 | // TODO: Do we need this on tvOS? 9 | // Should the app work without authentication at all? 10 | /// Executed when webservice authentication fails 11 | public var authenticationFailure: (() -> ())? 12 | 13 | public init(webservice: Webservice) { 14 | self.webservice = CachedWebservice(webservice: webservice) 15 | } 16 | 17 | public func root() -> UITabBarController { 18 | return UITabBarController() 19 | } 20 | 21 | public func videosTab(title: String = "Episodes") -> UINavigationController { 22 | let navVC = UINavigationController() 23 | navVC.title = title 24 | return navVC 25 | } 26 | 27 | public func allEpisodes( 28 | title: String = "Episodes", 29 | didSelect: @escaping (Episode) -> () 30 | ) -> UIViewController { 31 | let season = TableViewController(title: title, items: [], estimatedRowHeight: 140, 32 | loadData: { [weak self] loadType, completion in 33 | self?.load(Episode.all, skipCache: loadType == .forceReload, completion: completion) 34 | }, configureCell: { [weak self] (cell: EpisodeCell, episode) in 35 | guard let `self` = self else { return } 36 | let viewModel = EpisodeViewModel(episode: episode, loggedIn: false, webservice: self.webservice) 37 | cell.configure(viewModel: viewModel) 38 | } 39 | ) 40 | season.didSelect = didSelect 41 | return ReadableContentViewController(season) 42 | } 43 | 44 | public func episode(_ episode: Episode, didTapPlay: @escaping (Episode) -> ()) -> UIViewController { 45 | // TODO: implement loggedIn state 46 | return EpisodeDetailViewController(viewModel: EpisodeViewModel(episode: episode, loggedIn: false, webservice: webservice), didTapPlay: didTapPlay) 47 | } 48 | 49 | public func video(_ episode: Episode) -> VideoPlayerViewController { 50 | let vc = VideoPlayerViewController(episode: episode) 51 | webservice.load(episode.thumbnail, skipCache: false) { [weak vc] result in 52 | vc?.thumbnail = result.value 53 | } 54 | return vc 55 | } 56 | 57 | } 58 | 59 | public extension UIViewController { 60 | func presentAlert(message: String) { 61 | DispatchQueue.main.async { 62 | let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) 63 | let closeButton = UIAlertAction(title: "Close", style: .default, handler: nil) 64 | alert.addAction(closeButton) 65 | self.present(alert, animated: true, completion: nil) 66 | } 67 | } 68 | } 69 | 70 | fileprivate extension Screens { 71 | func load(_ resource: Resource, skipCache: Bool = false, completion: @escaping (A?) -> ()) { 72 | webservice.load(resource, skipCache: skipCache) { [weak self] result in 73 | if case .error(WebServiceError.notAuthenticated) = result { 74 | self?.authenticationFailure?() 75 | } 76 | completion(result.value) 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /SwiftTalk/StackView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginViewController.swift 3 | // Videos 4 | // 5 | // Created by Florian on 14/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | public enum ContentElement { 12 | case label(String, UIFontTextStyle, NSTextAlignment) 13 | case styledLabel(NSAttributedString) 14 | case textView(NSAttributedString) 15 | case custom(UIView) 16 | case button(title: String, callback: () -> ()) 17 | 18 | public init(text: String, style: UIFontTextStyle = .body, alignment: NSTextAlignment = .left) { 19 | self = .label(text, style, alignment) 20 | } 21 | } 22 | 23 | public final class ButtonWithTarget: UIButton { 24 | public var onPrimaryActionTriggered: (() -> ())? { 25 | didSet { 26 | addTarget(self, action: #selector(tapped(_:)), for: .primaryActionTriggered) 27 | } 28 | } 29 | 30 | func tapped(_ sender: AnyObject) { 31 | onPrimaryActionTriggered?() 32 | } 33 | } 34 | 35 | extension ContentElement { 36 | public var view: UIView { 37 | switch self { 38 | case .label(let text, let style, let alignment): 39 | let label = UILabel() 40 | label.text = text 41 | label.font = UIFont.preferredFont(forTextStyle: style) 42 | label.textAlignment = alignment 43 | label.numberOfLines = 0 44 | return label 45 | case .styledLabel(let text): 46 | let label = UILabel() 47 | label.attributedText = text 48 | label.numberOfLines = 0 49 | return label 50 | case .textView(let text): 51 | let textView = UITextView() 52 | #if !os(tvOS) 53 | textView.isEditable = false 54 | #endif 55 | textView.attributedText = text 56 | return textView 57 | case .button(let text, let callback): 58 | let button = ButtonWithTarget(type: .system) 59 | button.setTitle(text, for: .normal) 60 | button.onPrimaryActionTriggered = callback 61 | return button 62 | case .custom(let view): 63 | return view 64 | } 65 | } 66 | } 67 | 68 | extension UIStackView { 69 | public convenience init(axis: UILayoutConstraintAxis = .vertical, spacing: CGFloat = 20, distribution: UIStackViewDistribution = .fill, content: [ContentElement]) { 70 | self.init() 71 | self.axis = axis 72 | self.spacing = spacing 73 | self.distribution = distribution 74 | for element in content { 75 | addArrangedSubview(element.view) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /SwiftTalk/StandardViewControllers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StandardViewControllers.swift 3 | // Videos 4 | // 5 | // Created by Florian on 13/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | // Idea from http://www.thedotpost.com/2016/01/ayaka-nonaka-going-swift-and-beyond-first-wave-swift 12 | 13 | public protocol LoadingViewDelegate: class { 14 | func willAddContent() 15 | func didAddContent() 16 | } 17 | 18 | public final class LoadingView: UIView { 19 | let spinner = UIActivityIndicatorView(activityIndicatorStyle: .white) 20 | weak var delegate: LoadingViewDelegate? 21 | 22 | public init(load: (_ callback: @escaping (A) -> ()) -> (), build: @escaping (A) -> UIView) { 23 | super.init(frame: .zero) 24 | 25 | spinner.startAnimating() 26 | backgroundColor = UIColor.black 27 | addSubview(spinner) 28 | spinner.hidesWhenStopped = true 29 | spinner.translatesAutoresizingMaskIntoConstraints = false 30 | spinner.center(inView: self) 31 | layoutMargins = UIEdgeInsets() 32 | 33 | load { [weak self] data in 34 | self?.show(build(data)) 35 | } 36 | } 37 | 38 | required public init?(coder aDecoder: NSCoder) { 39 | fatalError("init(coder:) has not been implemented") 40 | } 41 | 42 | 43 | public func show(_ content: UIView) { 44 | spinner.stopAnimating() 45 | delegate?.willAddContent() 46 | addSubview(content) 47 | content.translatesAutoresizingMaskIntoConstraints = false 48 | content.constrainEdges(toMarginOf: self) 49 | delegate?.didAddContent() 50 | } 51 | 52 | } 53 | 54 | public final class LoadingViewController: UIViewController, LoadingViewDelegate { 55 | var loadingView: LoadingView? 56 | var contentViewController: UIViewController? 57 | 58 | init(load: (_ callback: @escaping (A) -> ()) -> (), build: @escaping (A) -> UIViewController) { 59 | super.init(nibName: nil, bundle: nil) 60 | loadingView = LoadingView(load: load, build: { [weak self] a in 61 | let viewController = build(a) 62 | self?.contentViewController = viewController 63 | return viewController.view 64 | }) 65 | loadingView?.delegate = self 66 | } 67 | 68 | public override func viewDidLoad() { 69 | super.viewDidLoad() 70 | 71 | guard let loadingView = loadingView else { fatalError("needs loadingView") } 72 | view.addSubview(loadingView) 73 | loadingView.translatesAutoresizingMaskIntoConstraints = false 74 | loadingView.topAnchor.constrainEqual(view.topAnchor) 75 | loadingView.leadingAnchor.constrainEqual(view.leadingAnchor) 76 | loadingView.trailingAnchor.constrainEqual(view.trailingAnchor) 77 | loadingView.bottomAnchor.constrainEqual(bottomLayoutGuide.topAnchor) 78 | } 79 | 80 | required public init?(coder aDecoder: NSCoder) { 81 | fatalError("init(coder:) has not been implemented") 82 | } 83 | 84 | public func willAddContent() { 85 | guard let contentViewController = contentViewController else { fatalError("needs to have contentViewController") } 86 | addChildViewController(contentViewController) 87 | } 88 | 89 | public func didAddContent() { 90 | guard let contentViewController = contentViewController else { fatalError("needs to have contentViewController") } 91 | contentViewController.didMove(toParentViewController: self) 92 | self.title = contentViewController.title 93 | 94 | } 95 | } 96 | 97 | public enum LoadType { 98 | case regular 99 | case forceReload 100 | } 101 | 102 | public final class TableViewController: UITableViewController { 103 | var items: [Item] { 104 | didSet { 105 | tableView.reloadData() 106 | } 107 | } 108 | 109 | let cellIdentifier = "CellIdentifier" 110 | let configureCell: (Cell, Item) -> () 111 | let estimatedRowHeight: CGFloat 112 | public var didSelect: ((Item) -> ())? 113 | public var didTapAccessory: ((Item) -> ())? 114 | 115 | public typealias Reload = (LoadType, @escaping ([Item]?) -> ()) -> () 116 | let loadData: Reload? 117 | 118 | /// Creates an instance showing `items`. 119 | /// 120 | /// - Parameter loadData: function that gets called after initialization and on pull to refresh. Make sure to call `endLoading` when loading has completed. 121 | /// 122 | /// - Note: the table view will only have a refresh control if you provide `loadData`. 123 | public init(style: UITableViewStyle = .plain, title: String, items: [Item], estimatedRowHeight: CGFloat = 44, loadData: Reload? = nil, configureCell: @escaping (Cell, Item) -> ()) { 124 | self.items = items 125 | self.configureCell = configureCell 126 | self.estimatedRowHeight = estimatedRowHeight 127 | self.loadData = loadData 128 | super.init(style: style) 129 | self.title = title 130 | tableView.register(Cell.self, forCellReuseIdentifier: cellIdentifier) 131 | #if os(iOS) 132 | addRefreshControlIfNeeded() 133 | refreshControl?.beginRefreshing() 134 | #endif 135 | reload(.regular) 136 | } 137 | 138 | required public init?(coder aDecoder: NSCoder) { 139 | fatalError("init(coder:) has not been implemented") 140 | } 141 | 142 | public override func viewDidLoad() { 143 | super.viewDidLoad() 144 | // Use self-sizing cells 145 | tableView.estimatedRowHeight = estimatedRowHeight 146 | tableView.rowHeight = UITableViewAutomaticDimension 147 | } 148 | 149 | private func addRefreshControlIfNeeded() { 150 | guard loadData != nil else { return } 151 | #if os(iOS) 152 | refreshControl = UIRefreshControl() 153 | refreshControl?.addTarget(self, action: #selector(startRefresh), for: .valueChanged) 154 | #endif 155 | } 156 | 157 | public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 158 | return items.count 159 | } 160 | 161 | public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 162 | let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! Cell 163 | configureCell(cell, items[indexPath.row]) 164 | return cell 165 | } 166 | 167 | public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 168 | didSelect?(items[indexPath.row]) 169 | } 170 | 171 | public override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { 172 | didTapAccessory?(items[indexPath.row]) 173 | } 174 | 175 | @objc private func startRefresh(_ sender: AnyObject) { 176 | reload(.forceReload) 177 | } 178 | 179 | private func reload(_ type: LoadType) { 180 | loadData?(type) { [weak self] data in 181 | if let data = data { 182 | self?.items = data 183 | } 184 | #if os(iOS) 185 | self?.refreshControl?.endRefreshing() 186 | #endif 187 | } 188 | } 189 | } 190 | 191 | -------------------------------------------------------------------------------- /SwiftTalk/VideoPlayerViewController.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import AVKit 3 | import UIKit 4 | 5 | public extension AVPlayerItem { 6 | static let didPlayToEndTime = NotificationDescriptor<()>(name: .AVPlayerItemDidPlayToEndTime) { _ in () } 7 | } 8 | 9 | public final class VideoPlayerViewController: UIViewController { 10 | let episode: Episode 11 | let playerViewController = AVPlayerViewController() 12 | public var thumbnail: UIImage? { 13 | didSet { 14 | guard let playerItem = playerViewController.player?.currentItem else { return } 15 | let artwork = AVMetadataItem.item(identifier: AVMetadataCommonIdentifierArtwork, image: thumbnail) 16 | playerItem.externalMetadata = playerItem.externalMetadata + [artwork] 17 | } 18 | } 19 | public var didPlayToEnd: (() -> ())? 20 | private var didPlayToEndTimeToken: NotificationToken? 21 | 22 | public init(episode: Episode) { 23 | self.episode = episode 24 | super.init(nibName: nil, bundle: nil) 25 | 26 | let playerItem = AVPlayerItem(url: episode.mediaURL) 27 | // Metadata to be displayed in the Info panel on swipe down. 28 | playerItem.externalMetadata = [ 29 | AVMetadataItem.item(identifier: AVMetadataCommonIdentifierTitle, value: episode.title), 30 | AVMetadataItem.item(identifier: AVMetadataCommonIdentifierDescription, value: episode.synopsis) 31 | ] 32 | 33 | let player = AVPlayer(playerItem: playerItem) 34 | playerViewController.player = player 35 | // Disable the Subtitles menu. As I understand the documentation, setting this to an empty array should work, but AVPlayerViewController seems to treat [] like nil. By setting it to "en", we effectively disable the subtitles selection as long as the media files don't contain an English subtitle track (and when they do in the future, it would be available for selection). 36 | playerViewController.allowedSubtitleOptionLanguages = ["en"] 37 | } 38 | 39 | required public init?(coder decoder: NSCoder) { 40 | fatalError("init(coder:) has not been implemented") 41 | } 42 | 43 | public override func loadView() { 44 | view = UIView() 45 | 46 | addChildViewController(playerViewController) 47 | view.addSubview(playerViewController.view) 48 | playerViewController.didMove(toParentViewController: self) 49 | 50 | playerViewController.view.topAnchor.constrainEqual(view.topAnchor) 51 | playerViewController.view.leadingAnchor.constrainEqual(view.leadingAnchor) 52 | playerViewController.view.trailingAnchor.constrainEqual(view.trailingAnchor) 53 | playerViewController.view.bottomAnchor.constrainEqual(view.bottomAnchor) 54 | } 55 | 56 | public override func viewDidAppear(_ animated: Bool) { 57 | super.viewDidAppear(animated) 58 | if let playerItem = playerViewController.player?.currentItem { 59 | didPlayToEndTimeToken = NotificationCenter.default.addObserver(descriptor: AVPlayerItem.didPlayToEndTime, object: playerItem) { [unowned self] _ in 60 | self.didPlayToEnd?() 61 | } 62 | } 63 | } 64 | 65 | public override func viewWillDisappear(_ animated: Bool) { 66 | super.viewWillDisappear(animated) 67 | didPlayToEndTimeToken = nil 68 | } 69 | 70 | public func play() { 71 | playerViewController.player?.play() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /SwiftTalk/Webservice.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Webservice.swift 3 | // Videos 4 | // 5 | // Created by Florian on 13/04/16. 6 | // Copyright © 2016 Chris Eidhof. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | extension Episode { 12 | public static var all: Resource<[Episode]> = try! Resource( 13 | url: Environment.current.baseURL.appendingPathComponent("episodes.json"), 14 | parseElement: Episode.init 15 | ) 16 | } 17 | 18 | extension Episode { 19 | public var thumbnail: Resource { 20 | return Resource(url: thumbnailURL, parse: { UIImage(data: $0 as Data) }, method: .get) 21 | } 22 | } 23 | 24 | //public func pushNotificationRegistration(_ token: Data) -> Resource<()> { 25 | // let json: JSONDictionary = ["token": token.hexadecimalString] 26 | // return try! Resource<()>(url: URL(string: "https://swifttalk-staging.herokuapp.com/push_notification_token")!, method: .post(data: json), parseJSON: { _ in () }) 27 | //} 28 | 29 | public enum Result { 30 | case success(A) 31 | case error(Error) 32 | } 33 | 34 | extension Result { 35 | public init(_ value: A?, or error: Error) { 36 | if let value = value { 37 | self = .success(value) 38 | } else { 39 | self = .error(error) 40 | } 41 | } 42 | 43 | public var value: A? { 44 | guard case .success(let v) = self else { return nil } 45 | return v 46 | } 47 | } 48 | 49 | 50 | public enum WebServiceError: Error { 51 | case notAuthenticated 52 | case other 53 | } 54 | 55 | func logError(_ result: Result) { 56 | guard case let .error(e) = result else { return } 57 | assert(false, "\(e)") 58 | } 59 | 60 | public final class Webservice { 61 | public var authenticationToken: AuthToken? 62 | public init() { } 63 | 64 | /// Loads a resource. The completion handler is always called on the main queue. 65 | public func load(_ resource: Resource, completion: @escaping (Result) -> () = logError) { 66 | URLSession.shared.dataTask(with: resource.urlRequest, completionHandler: { data, response, _ in 67 | let result: Result 68 | if let httpResponse = response as? HTTPURLResponse , httpResponse.statusCode == 401 { 69 | result = Result.error(WebServiceError.notAuthenticated) 70 | } else { 71 | let parsed = data.flatMap(resource.parse) 72 | result = Result(parsed, or: WebServiceError.other) 73 | } 74 | mainQueue { completion(result) } 75 | }) .resume() 76 | } 77 | } 78 | 79 | 80 | 81 | --------------------------------------------------------------------------------